重构 改为标准化输出
@@ -0,0 +1,7 @@
|
|||||||
|
"""Autonomous CAD protocol v3.
|
||||||
|
|
||||||
|
The package intentionally separates policy from I/O. Delivery code composes
|
||||||
|
these modules with adapters; it must not bypass the command handlers.
|
||||||
|
"""
|
||||||
|
|
||||||
|
PROTOCOL_VERSION = "3.0"
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Infrastructure adapters for the v3 ports."""
|
||||||
@@ -0,0 +1,400 @@
|
|||||||
|
"""Immutable v3 file artifacts with staging manifests and atomic publication."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from hashlib import sha256
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
import secrets
|
||||||
|
import shutil
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.cad_agent.ports import CandidateStage
|
||||||
|
|
||||||
|
|
||||||
|
_SAFE_RELATIVE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._/-]{0,240}$")
|
||||||
|
|
||||||
|
|
||||||
|
class FileArtifactStore:
|
||||||
|
def __init__(self, root: Path) -> None:
|
||||||
|
# Keep every externally reported artifact path in the same canonical
|
||||||
|
# form as staging and publication paths (notably /var vs /private/var
|
||||||
|
# on macOS).
|
||||||
|
self.root = root.resolve()
|
||||||
|
self.root.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
def task_dir(self, task_id: str) -> Path:
|
||||||
|
if not re.fullmatch(r"cad_[a-z0-9]{12}", task_id):
|
||||||
|
raise ValueError("Invalid v3 task id")
|
||||||
|
return self.root / task_id
|
||||||
|
|
||||||
|
def artifact_path(self, task_id: str, relative_path: str) -> Path:
|
||||||
|
return self._path(task_id, relative_path)
|
||||||
|
|
||||||
|
def initialize_task(
|
||||||
|
self,
|
||||||
|
task_id: str,
|
||||||
|
request: str,
|
||||||
|
*,
|
||||||
|
source_blocks: list[dict[str, Any]] | None = None,
|
||||||
|
) -> None:
|
||||||
|
root = self.task_dir(task_id)
|
||||||
|
(root / "documents").mkdir(parents=True, exist_ok=True)
|
||||||
|
(root / "actions").mkdir(exist_ok=True)
|
||||||
|
(root / "revisions").mkdir(exist_ok=True)
|
||||||
|
(root / ".staging").mkdir(exist_ok=True)
|
||||||
|
source = root / "source-requirements.md"
|
||||||
|
blocks = self._source_blocks(request, source_blocks)
|
||||||
|
if not source.exists():
|
||||||
|
self._write_once(source, "\n\n".join(block["text"] for block in blocks) + "\n")
|
||||||
|
self.write_source_index(task_id, request, source_blocks=blocks)
|
||||||
|
|
||||||
|
def sync_action_ledger(self, task_id: str, events: list[dict[str, Any]]) -> str:
|
||||||
|
"""Mirror committed SQLite events into an append-only JSONL audit log.
|
||||||
|
|
||||||
|
SQLite remains authoritative. Replaying this method after an
|
||||||
|
interruption appends only missing committed sequences and rejects a
|
||||||
|
divergent line instead of rewriting audit history.
|
||||||
|
"""
|
||||||
|
path = self._path(task_id, "actions/action-ledger.jsonl")
|
||||||
|
existing: dict[int, dict[str, Any]] = {}
|
||||||
|
if path.is_file():
|
||||||
|
for raw in path.read_text(encoding="utf-8").splitlines():
|
||||||
|
if not raw.strip():
|
||||||
|
continue
|
||||||
|
value = json.loads(raw)
|
||||||
|
sequence = value.get("sequence") if isinstance(value, dict) else None
|
||||||
|
if not isinstance(sequence, int) or sequence < 1:
|
||||||
|
raise ValueError("Action ledger contains an invalid sequence")
|
||||||
|
existing[sequence] = value
|
||||||
|
missing: list[str] = []
|
||||||
|
for event in events:
|
||||||
|
sequence = event.get("sequence")
|
||||||
|
if not isinstance(sequence, int) or sequence < 1:
|
||||||
|
raise ValueError("Action ledger event has an invalid sequence")
|
||||||
|
entry = {"schema_version": "cad.action-ledger.v1", **event}
|
||||||
|
previous = existing.get(sequence)
|
||||||
|
if previous is not None:
|
||||||
|
if previous != entry:
|
||||||
|
raise ValueError("Action ledger diverges from committed SQLite event")
|
||||||
|
continue
|
||||||
|
missing.append(json.dumps(entry, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + "\n")
|
||||||
|
if missing:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with path.open("a", encoding="utf-8") as handle:
|
||||||
|
handle.writelines(missing)
|
||||||
|
handle.flush()
|
||||||
|
os.fsync(handle.fileno())
|
||||||
|
return "actions/action-ledger.jsonl"
|
||||||
|
|
||||||
|
def write_source_index(
|
||||||
|
self,
|
||||||
|
task_id: str,
|
||||||
|
request: str,
|
||||||
|
*,
|
||||||
|
source_blocks: list[dict[str, Any]] | None = None,
|
||||||
|
) -> dict[str, str]:
|
||||||
|
root = self.task_dir(task_id)
|
||||||
|
source = root / "source-requirements.md"
|
||||||
|
if source_blocks is None:
|
||||||
|
text = source.read_text(encoding="utf-8") if source.is_file() else request
|
||||||
|
blocks = self._source_blocks(text, None)
|
||||||
|
else:
|
||||||
|
blocks = self._source_blocks(request, source_blocks)
|
||||||
|
index = {f"src_{position:03d}": block["text"] for position, block in enumerate(blocks, 1)}
|
||||||
|
attachment_blocks = {
|
||||||
|
f"src_{position:03d}": block["attachment"]
|
||||||
|
for position, block in enumerate(blocks, 1)
|
||||||
|
if isinstance(block.get("attachment"), dict)
|
||||||
|
}
|
||||||
|
payload: dict[str, Any] = {"schema_version": "cad.source-index.v1", "sources": index}
|
||||||
|
if attachment_blocks:
|
||||||
|
payload["attachment_blocks"] = attachment_blocks
|
||||||
|
self._write_json_once(root / "documents" / "source-index.json", payload)
|
||||||
|
return index
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _source_blocks(request: str, source_blocks: list[dict[str, Any]] | None) -> list[dict[str, Any]]:
|
||||||
|
"""Return immutable source paragraphs and attachment blocks.
|
||||||
|
|
||||||
|
Source identifiers are assigned only after this method returns, so
|
||||||
|
callers cannot choose IDs. Attachment metadata is deliberately a
|
||||||
|
closed, server-provided projection: it lets a reviewer trace the
|
||||||
|
source without treating upload metadata as arbitrary LLM input.
|
||||||
|
"""
|
||||||
|
supplied = source_blocks
|
||||||
|
if supplied is None:
|
||||||
|
supplied = [{"text": value.strip()} for value in re.split(r"\n\s*\n", request) if value.strip()]
|
||||||
|
blocks: list[dict[str, Any]] = []
|
||||||
|
for item in supplied:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
raise ValueError("Source blocks must be objects")
|
||||||
|
text = item.get("text")
|
||||||
|
if not isinstance(text, str) or not text.strip():
|
||||||
|
raise ValueError("Every source block requires non-empty text")
|
||||||
|
if len(text) > 30_000:
|
||||||
|
raise ValueError("Source block exceeds the 30000 character limit")
|
||||||
|
block: dict[str, Any] = {"text": text.strip()}
|
||||||
|
attachment = item.get("attachment")
|
||||||
|
if attachment is not None:
|
||||||
|
if not isinstance(attachment, dict):
|
||||||
|
raise ValueError("Source attachment metadata must be an object")
|
||||||
|
allowed = {"attachment_id", "name", "kind", "mime", "sha256"}
|
||||||
|
if set(attachment) - allowed:
|
||||||
|
raise ValueError("Source attachment metadata contains unsupported fields")
|
||||||
|
attachment_id = attachment.get("attachment_id")
|
||||||
|
digest = attachment.get("sha256")
|
||||||
|
if not isinstance(attachment_id, str) or not re.fullmatch(r"[A-Za-z0-9_-]{1,128}", attachment_id):
|
||||||
|
raise ValueError("Source attachment id is invalid")
|
||||||
|
if not isinstance(digest, str) or not re.fullmatch(r"[a-f0-9]{64}", digest):
|
||||||
|
raise ValueError("Source attachment SHA-256 is invalid")
|
||||||
|
block["attachment"] = {
|
||||||
|
"attachment_id": attachment_id,
|
||||||
|
"name": str(attachment.get("name") or "attachment")[:255],
|
||||||
|
"kind": str(attachment.get("kind") or "document")[:32],
|
||||||
|
"mime": str(attachment.get("mime") or "application/octet-stream")[:128],
|
||||||
|
"sha256": digest,
|
||||||
|
}
|
||||||
|
blocks.append(block)
|
||||||
|
if not blocks:
|
||||||
|
raise ValueError("At least one source block is required")
|
||||||
|
return blocks
|
||||||
|
|
||||||
|
def read_source_index(self, task_id: str) -> dict[str, str]:
|
||||||
|
payload = self.read_json(task_id, "documents/source-index.json") or {}
|
||||||
|
sources = payload.get("sources") if isinstance(payload.get("sources"), dict) else {}
|
||||||
|
return {str(key): str(value) for key, value in sources.items()}
|
||||||
|
|
||||||
|
def read_source_requirements(self, task_id: str) -> str:
|
||||||
|
path = self.task_dir(task_id) / "source-requirements.md"
|
||||||
|
return path.read_text(encoding="utf-8") if path.is_file() else ""
|
||||||
|
|
||||||
|
def read_requirements_draft(self, task_id: str, artifact_path: str = "") -> dict[str, Any]:
|
||||||
|
if artifact_path:
|
||||||
|
value = self.read_json(task_id, artifact_path)
|
||||||
|
if value is None:
|
||||||
|
raise ValueError("Committed requirements draft artifact is unavailable")
|
||||||
|
return value
|
||||||
|
documents = self.task_dir(task_id) / "documents"
|
||||||
|
candidates = sorted(documents.glob("requirements-draft-v*.json"))
|
||||||
|
if not candidates:
|
||||||
|
return {"schema_version": "cad.requirements-draft.v1", "revision": 0, "items": []}
|
||||||
|
value = json.loads(candidates[-1].read_text(encoding="utf-8"))
|
||||||
|
return value if isinstance(value, dict) else {"schema_version": "cad.requirements-draft.v1", "revision": 0, "items": []}
|
||||||
|
|
||||||
|
def write_requirements_draft(self, task_id: str, payload: dict[str, Any], *, invocation_id: str) -> str:
|
||||||
|
revision = int(payload.get("revision") or 0)
|
||||||
|
if revision < 1:
|
||||||
|
raise ValueError("Requirements draft revision is invalid")
|
||||||
|
return self._write_invocation_json(task_id, f"requirements-draft-v{revision}", payload, invocation_id)
|
||||||
|
|
||||||
|
def read_requirements_review(self, task_id: str, artifact_path: str = "") -> dict[str, Any] | None:
|
||||||
|
return self.read_json(task_id, artifact_path) if artifact_path else None
|
||||||
|
|
||||||
|
def write_requirements_review(self, task_id: str, payload: dict[str, Any], *, invocation_id: str) -> str:
|
||||||
|
return self._write_invocation_json(task_id, "requirements-review", payload, invocation_id)
|
||||||
|
|
||||||
|
def read_requirements_contract(self, task_id: str, artifact_path: str = "") -> dict[str, Any] | None:
|
||||||
|
# State points to the immutable artifact used as program input. The
|
||||||
|
# fixed name is a read-only convenience view for terminal tasks.
|
||||||
|
return self.read_json(task_id, artifact_path or "requirements-contract.json")
|
||||||
|
|
||||||
|
def write_requirements_contract(self, task_id: str, payload: dict[str, Any], *, invocation_id: str = "") -> str:
|
||||||
|
if not invocation_id:
|
||||||
|
return self.write_json_once(task_id, "requirements-contract.json", payload)
|
||||||
|
return self._write_invocation_json(task_id, "requirements-contract", payload, invocation_id)
|
||||||
|
|
||||||
|
def read_json(self, task_id: str, relative_path: str) -> dict[str, Any] | None:
|
||||||
|
path = self._path(task_id, relative_path)
|
||||||
|
if not path.is_file():
|
||||||
|
return None
|
||||||
|
value = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
return value if isinstance(value, dict) else None
|
||||||
|
|
||||||
|
def write_json_once(self, task_id: str, relative_path: str, payload: dict[str, Any]) -> str:
|
||||||
|
path = self._path(task_id, relative_path)
|
||||||
|
self._write_json_once(path, payload)
|
||||||
|
return relative_path
|
||||||
|
|
||||||
|
def write_text_once(self, task_id: str, relative_path: str, text: str) -> str:
|
||||||
|
path = self._path(task_id, relative_path)
|
||||||
|
self._write_once(path, text)
|
||||||
|
return relative_path
|
||||||
|
|
||||||
|
def read_active_cdsl(self, task_id: str, revision_id: str) -> dict[str, Any] | None:
|
||||||
|
return self.read_json(task_id, f"revisions/{revision_id}/model.cdsl.json") if revision_id else None
|
||||||
|
|
||||||
|
def read_topology(self, task_id: str, revision_id: str) -> dict[str, Any] | None:
|
||||||
|
return self.read_json(task_id, f"revisions/{revision_id}/model.topology.json") if revision_id else None
|
||||||
|
|
||||||
|
def start_candidate_stage(self, task_id: str, idempotency_key: str, payload: dict[str, Any]) -> CandidateStage:
|
||||||
|
root = self.task_dir(task_id) / ".staging"
|
||||||
|
stable_id = "stage_" + sha256(idempotency_key.encode("utf-8")).hexdigest()[:20]
|
||||||
|
directory = root / stable_id
|
||||||
|
directory.mkdir(parents=True, exist_ok=True)
|
||||||
|
self._write_json_once(directory / "input.json", payload)
|
||||||
|
return CandidateStage(stable_id, str(directory.resolve()))
|
||||||
|
|
||||||
|
def stage_output_dir(self, task_id: str, stage_id: str) -> str:
|
||||||
|
return str(self._stage_path(task_id, stage_id, ""))
|
||||||
|
|
||||||
|
def write_stage_json(self, task_id: str, stage_id: str, relative_path: str, payload: dict[str, Any]) -> str:
|
||||||
|
path = self._stage_path(task_id, stage_id, relative_path)
|
||||||
|
self._write_json_once(path, payload)
|
||||||
|
return relative_path
|
||||||
|
|
||||||
|
def read_stage_json(self, task_id: str, stage_id: str, relative_path: str) -> dict[str, Any] | None:
|
||||||
|
path = self._stage_path(task_id, stage_id, relative_path)
|
||||||
|
if not path.is_file():
|
||||||
|
return None
|
||||||
|
value = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
return value if isinstance(value, dict) else None
|
||||||
|
|
||||||
|
def publish_candidate(self, task_id: str, stage_id: str, revision_id: str) -> dict[str, str]:
|
||||||
|
source = self._stage_path(task_id, stage_id, "")
|
||||||
|
target = self._path(task_id, f"revisions/{revision_id}")
|
||||||
|
if target.exists():
|
||||||
|
manifest = self._manifest(target)
|
||||||
|
if manifest is None:
|
||||||
|
raise RuntimeError("Published revision has no valid manifest")
|
||||||
|
return {key: f"revisions/{revision_id}/{key}" for key in manifest["files"]}
|
||||||
|
# Render/rebuild reports contain paths for the reviewer. Rebase those
|
||||||
|
# paths while artifacts are still mutable staging output, so they point
|
||||||
|
# at the revision after the atomic directory rename.
|
||||||
|
self._rebase_staged_paths(source, target)
|
||||||
|
manifest = self._create_manifest(source)
|
||||||
|
self._write_json_once(source / "manifest.json", manifest)
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
os.replace(source, target)
|
||||||
|
return {key: f"revisions/{revision_id}/{key}" for key in manifest["files"]}
|
||||||
|
|
||||||
|
def find_published_candidate(self, task_id: str, stage_id: str) -> tuple[str, dict[str, Any]] | None:
|
||||||
|
"""Find a manifest-verified candidate already renamed before its DB CAS.
|
||||||
|
|
||||||
|
Publishing artifacts and advancing SQLite cannot share a transaction.
|
||||||
|
The stage id persisted inside ``candidate.json`` makes a post-rename
|
||||||
|
recovery deterministic and prevents another build or revision.
|
||||||
|
"""
|
||||||
|
revisions = self.task_dir(task_id) / "revisions"
|
||||||
|
if not revisions.is_dir():
|
||||||
|
return None
|
||||||
|
for revision in sorted(revisions.iterdir()):
|
||||||
|
if not revision.is_dir() or self._manifest(revision) is None:
|
||||||
|
continue
|
||||||
|
candidate_path = revision / "candidate.json"
|
||||||
|
if not candidate_path.is_file():
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
candidate = json.loads(candidate_path.read_text(encoding="utf-8"))
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
if isinstance(candidate, dict) and candidate.get("stage_id") == stage_id:
|
||||||
|
return revision.name, candidate
|
||||||
|
return None
|
||||||
|
|
||||||
|
def recover_staged_candidates(self, task_id: str, referenced_stage_ids: set[str]) -> None:
|
||||||
|
root = self.task_dir(task_id) / ".staging"
|
||||||
|
if not root.is_dir():
|
||||||
|
return
|
||||||
|
for directory in root.iterdir():
|
||||||
|
if not directory.is_dir() or directory.name in referenced_stage_ids:
|
||||||
|
continue
|
||||||
|
shutil.rmtree(directory)
|
||||||
|
|
||||||
|
def _path(self, task_id: str, relative_path: str) -> Path:
|
||||||
|
if relative_path and (not _SAFE_RELATIVE.fullmatch(relative_path) or ".." in Path(relative_path).parts):
|
||||||
|
raise ValueError("Invalid artifact relative path")
|
||||||
|
root = self.task_dir(task_id).resolve()
|
||||||
|
target = (root / relative_path).resolve()
|
||||||
|
if target != root and root not in target.parents:
|
||||||
|
raise ValueError("Artifact path escapes its task")
|
||||||
|
return target
|
||||||
|
|
||||||
|
def _stage_path(self, task_id: str, stage_id: str, relative_path: str) -> Path:
|
||||||
|
if not re.fullmatch(r"stage_[a-f0-9]{20}", stage_id):
|
||||||
|
raise ValueError("Invalid candidate stage id")
|
||||||
|
root = self.task_dir(task_id).resolve()
|
||||||
|
stage = (root / ".staging" / stage_id).resolve()
|
||||||
|
if root not in stage.parents:
|
||||||
|
raise ValueError("Stage path escapes task directory")
|
||||||
|
if relative_path and (not _SAFE_RELATIVE.fullmatch(relative_path) or ".." in Path(relative_path).parts):
|
||||||
|
raise ValueError("Invalid stage artifact path")
|
||||||
|
path = (stage / relative_path).resolve()
|
||||||
|
if path != stage and stage not in path.parents:
|
||||||
|
raise ValueError("Stage artifact path escapes staging directory")
|
||||||
|
return path
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _write_once(path: Path, text: str) -> None:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
encoded = text.encode("utf-8")
|
||||||
|
if path.exists():
|
||||||
|
if path.read_bytes() != encoded:
|
||||||
|
raise ValueError(f"Immutable artifact already exists: {path.name}")
|
||||||
|
return
|
||||||
|
temporary = path.with_name(path.name + ".tmp-" + secrets.token_hex(4))
|
||||||
|
temporary.write_bytes(encoded)
|
||||||
|
os.replace(temporary, path)
|
||||||
|
|
||||||
|
def _write_invocation_json(self, task_id: str, stem: str, payload: dict[str, Any], invocation_id: str) -> str:
|
||||||
|
digest = sha256(json.dumps(payload, ensure_ascii=True, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest()[:12]
|
||||||
|
safe_invocation = re.sub(r"[^A-Za-z0-9_-]", "", invocation_id)[:32]
|
||||||
|
if not safe_invocation:
|
||||||
|
raise ValueError("Artifact invocation ID is invalid")
|
||||||
|
return self.write_json_once(task_id, f"documents/{stem}-{digest}-{safe_invocation}.json", payload)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _write_json_once(cls, path: Path, payload: dict[str, Any]) -> None:
|
||||||
|
cls._write_once(path, json.dumps(payload, ensure_ascii=False, sort_keys=True, indent=2) + "\n")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _create_manifest(directory: Path) -> dict[str, Any]:
|
||||||
|
files: dict[str, str] = {}
|
||||||
|
for path in sorted(directory.rglob("*")):
|
||||||
|
if not path.is_file() or path.name == "manifest.json":
|
||||||
|
continue
|
||||||
|
relative = path.relative_to(directory).as_posix()
|
||||||
|
files[relative] = sha256(path.read_bytes()).hexdigest()
|
||||||
|
if not files:
|
||||||
|
raise RuntimeError("Candidate staging directory has no artifacts")
|
||||||
|
return {"schema_version": "cad.v3.artifact-manifest.v1", "files": files}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _manifest(directory: Path) -> dict[str, Any] | None:
|
||||||
|
path = directory / "manifest.json"
|
||||||
|
if not path.is_file():
|
||||||
|
return None
|
||||||
|
value = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
files = value.get("files") if isinstance(value, dict) else None
|
||||||
|
if not isinstance(files, dict):
|
||||||
|
return None
|
||||||
|
for relative, digest in files.items():
|
||||||
|
candidate = directory / str(relative)
|
||||||
|
if not candidate.is_file() or sha256(candidate.read_bytes()).hexdigest() != digest:
|
||||||
|
return None
|
||||||
|
return value
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _rebase_staged_paths(cls, source: Path, target: Path) -> None:
|
||||||
|
source_text = str(source)
|
||||||
|
target_text = str(target)
|
||||||
|
|
||||||
|
def rebase(value: Any) -> Any:
|
||||||
|
if isinstance(value, str):
|
||||||
|
return target_text + value.removeprefix(source_text) if value.startswith(source_text) else value
|
||||||
|
if isinstance(value, list):
|
||||||
|
return [rebase(item) for item in value]
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return {key: rebase(item) for key, item in value.items()}
|
||||||
|
return value
|
||||||
|
|
||||||
|
for path in source.rglob("*.json"):
|
||||||
|
try:
|
||||||
|
value = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
continue
|
||||||
|
rebased = rebase(value)
|
||||||
|
if rebased != value:
|
||||||
|
path.write_text(json.dumps(rebased, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"""In-process idempotent event delivery for the v3 delivery boundary."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from copy import deepcopy
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
class IdempotentInProcessPublisher:
|
||||||
|
"""A small delivery adapter with durable-outbox compatible semantics.
|
||||||
|
|
||||||
|
The process-local sink is intentionally only a delivery mechanism: SQLite
|
||||||
|
remains the durable event source. The event ID is retained so a later
|
||||||
|
durable broker/websocket adapter can make the same idempotency guarantee.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._seen_event_ids: set[int] = set()
|
||||||
|
self.delivered: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
async def publish(self, event: dict[str, Any]) -> None:
|
||||||
|
event_id = event.get("event_id")
|
||||||
|
if not isinstance(event_id, int):
|
||||||
|
raise ValueError("Published outbox event has no integer event_id")
|
||||||
|
if event_id in self._seen_event_ids:
|
||||||
|
return
|
||||||
|
self._seen_event_ids.add(event_id)
|
||||||
|
self.delivered.append(deepcopy(event))
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
"""Independent rendered-review adapter for protocol v3."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.cad_agent.adapters.structured_llm import StructuredModelGateway
|
||||||
|
from app.cad_agent.ports import AdapterUnavailable
|
||||||
|
|
||||||
|
|
||||||
|
class RenderedReviewGateway:
|
||||||
|
def __init__(self, models: StructuredModelGateway) -> None:
|
||||||
|
self.models = models
|
||||||
|
|
||||||
|
async def review(self, *, kind: str, payload: dict[str, Any], tool: dict[str, Any], provider_id: str, model_id: str) -> dict[str, Any]:
|
||||||
|
name = str((tool.get("function") or {}).get("name") or "")
|
||||||
|
if not name:
|
||||||
|
raise RuntimeError("Review tool is missing a name")
|
||||||
|
content: list[dict[str, Any]] = [{"type": "text", "text": json.dumps(payload, ensure_ascii=False)}]
|
||||||
|
if kind in {"candidate", "final"}:
|
||||||
|
manifest = payload.get("render_manifest") if isinstance(payload.get("render_manifest"), dict) else {}
|
||||||
|
for path in self._evidence_paths(manifest):
|
||||||
|
content.append(self._image_part(path))
|
||||||
|
return await self.models.call_tool(
|
||||||
|
messages=[
|
||||||
|
{"role": "system", "content": "You are an independent CAD reviewer. Inspect supplied deterministic facts and rendered images. Return only the specified structured tool call."},
|
||||||
|
{"role": "user", "content": content},
|
||||||
|
],
|
||||||
|
tool=tool,
|
||||||
|
provider_id=provider_id,
|
||||||
|
model_id=model_id,
|
||||||
|
required_tool_name=name,
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _evidence_paths(manifest: dict[str, Any]) -> list[Path]:
|
||||||
|
selected: list[Path] = []
|
||||||
|
contact = Path(str(manifest.get("contact_sheet_path") or ""))
|
||||||
|
if contact.is_file():
|
||||||
|
selected.append(contact)
|
||||||
|
wanted = {"top", "front", "right", "isometric"}
|
||||||
|
for item in manifest.get("views") or ():
|
||||||
|
if not isinstance(item, dict) or str(item.get("id") or "") not in wanted:
|
||||||
|
continue
|
||||||
|
path = Path(str(item.get("path") or ""))
|
||||||
|
if path.is_file():
|
||||||
|
selected.append(path)
|
||||||
|
if not selected:
|
||||||
|
raise AdapterUnavailable("RENDER_SERVICE_UNAVAILABLE: review render evidence is unavailable")
|
||||||
|
return selected[:5]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _image_part(path: Path) -> dict[str, Any]:
|
||||||
|
media_type = "image/jpeg" if path.suffix.lower() in {".jpg", ".jpeg"} else "image/png"
|
||||||
|
return {"type": "image_url", "image_url": {"url": f"data:{media_type};base64,{base64.b64encode(path.read_bytes()).decode('ascii')}"}}
|
||||||
@@ -0,0 +1,690 @@
|
|||||||
|
"""CDSL runtime adapter driven exclusively by profile operation contracts."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from copy import deepcopy
|
||||||
|
from hashlib import sha256
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.cad_agent.domain.operation_contract import (
|
||||||
|
SEMANTIC_PREFLIGHT_NAMES,
|
||||||
|
OperationContractError,
|
||||||
|
canonical_hash,
|
||||||
|
validate_fragment,
|
||||||
|
validate_operation_contract,
|
||||||
|
)
|
||||||
|
from app.cad_agent.domain.verifier_registry import default_registry
|
||||||
|
from app.services.engine_service import load_engine, topology_snapshot, validate_cdsl
|
||||||
|
from app.services.review_renderer import ReviewRenderError, render_checkpoint
|
||||||
|
from app.settings import Settings
|
||||||
|
from vendor.cdsl_preview_runtime import step_to_glb
|
||||||
|
|
||||||
|
|
||||||
|
class RuntimeAdapterError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ProfileCadRuntime:
|
||||||
|
"""Adapter that owns engine imports; application code sees only its port."""
|
||||||
|
|
||||||
|
def __init__(self, settings: Settings) -> None:
|
||||||
|
self.settings = settings
|
||||||
|
self.engine = load_engine(settings)
|
||||||
|
self._semantic_preflight_handlers = {
|
||||||
|
"sketch_workplane": self._preflight_sketch_workplane,
|
||||||
|
"profile_non_self_intersecting": self._preflight_profile_non_self_intersecting,
|
||||||
|
"host_face_exists": self._preflight_host_face_exists,
|
||||||
|
"hole_positions_on_host_plane": self._preflight_hole_positions_on_host_plane,
|
||||||
|
"cut_exit_distance": self._preflight_cut_exit_distance,
|
||||||
|
"requires_active_solid": self._preflight_requires_active_solid,
|
||||||
|
"revolve_axis_on_sketch": self._preflight_revolve_axis_on_sketch,
|
||||||
|
"reference_plane_nonzero_normal": self._preflight_reference_plane_nonzero_normal,
|
||||||
|
"reference_axis_nonzero_direction": self._preflight_reference_axis_nonzero_direction,
|
||||||
|
"selected_edges_exist": self._preflight_selected_edges_exist,
|
||||||
|
"source_features_exist": self._preflight_source_features_exist,
|
||||||
|
"mirror_plane_exists": self._preflight_mirror_plane_exists,
|
||||||
|
}
|
||||||
|
schema_path = Path(str(self.engine.__file__)).with_name("profile_schema.json")
|
||||||
|
try:
|
||||||
|
self._profile = json.loads(schema_path.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, json.JSONDecodeError) as error:
|
||||||
|
raise RuntimeAdapterError("RUNTIME_CONTRACT_INVALID: profile schema is unavailable") from error
|
||||||
|
self._contracts = self._profile.get("operation_contracts")
|
||||||
|
if not isinstance(self._contracts, dict):
|
||||||
|
raise RuntimeAdapterError("RUNTIME_CONTRACT_INVALID: profile has no v3 operation contracts")
|
||||||
|
declared = {str(item) for item in self._contracts}
|
||||||
|
registered = {str(item) for item in getattr(self.engine, "SUPPORTED_ATOMIC_IDS", ())}
|
||||||
|
if declared != registered:
|
||||||
|
raise RuntimeAdapterError("RUNTIME_CONTRACT_INVALID: runtime and profile operation registries disagree")
|
||||||
|
verifier_kinds = set(default_registry().claim_kinds)
|
||||||
|
for contract in self._contracts.values():
|
||||||
|
try:
|
||||||
|
validate_operation_contract(contract)
|
||||||
|
except OperationContractError as error:
|
||||||
|
raise RuntimeAdapterError(f"RUNTIME_CONTRACT_INVALID: {error}") from error
|
||||||
|
unknown_preflights = set(contract["semantic_preflight"]) - set(self._semantic_preflight_handlers)
|
||||||
|
unknown_verifiers = set(contract["candidate_verifiers"]) - verifier_kinds
|
||||||
|
if unknown_preflights or unknown_verifiers:
|
||||||
|
raise RuntimeAdapterError(
|
||||||
|
"RUNTIME_CONTRACT_INVALID: operation contract references an unavailable "
|
||||||
|
f"{'preflight' if unknown_preflights else 'candidate verifier'}"
|
||||||
|
)
|
||||||
|
if set(self._semantic_preflight_handlers) != SEMANTIC_PREFLIGHT_NAMES:
|
||||||
|
raise RuntimeAdapterError("RUNTIME_CONTRACT_INVALID: runtime preflight registry is incomplete")
|
||||||
|
|
||||||
|
def supported_atomic_ids(self) -> tuple[str, ...]:
|
||||||
|
return tuple(sorted(self._contracts))
|
||||||
|
|
||||||
|
def operation_contract(self, atomic_id: str) -> dict[str, Any]:
|
||||||
|
contract = self._contracts.get(atomic_id)
|
||||||
|
if not isinstance(contract, dict):
|
||||||
|
raise RuntimeAdapterError(f"RUNTIME_CONTRACT_INVALID: unknown operation {atomic_id}")
|
||||||
|
result = deepcopy(contract)
|
||||||
|
result["contract_hash"] = canonical_hash(contract)
|
||||||
|
result["registry_revision"] = str(self._profile.get("schema_version") or "")
|
||||||
|
return result
|
||||||
|
|
||||||
|
def selector_tokens(self, topology: dict[str, Any] | None) -> dict[str, dict[str, Any]]:
|
||||||
|
if not isinstance(topology, dict):
|
||||||
|
return {}
|
||||||
|
snapshot_id = str(topology.get("snapshot_id") or "")
|
||||||
|
if not snapshot_id:
|
||||||
|
return {}
|
||||||
|
result: dict[str, dict[str, Any]] = {}
|
||||||
|
for record in topology.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", "plane", "axis", "body", "vertex"}:
|
||||||
|
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 ""]
|
||||||
|
result[token] = {
|
||||||
|
"token": token,
|
||||||
|
"kind": kind,
|
||||||
|
"snapshot_id": snapshot_id,
|
||||||
|
"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},
|
||||||
|
"geometry": geometry,
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
|
||||||
|
def reference_tokens(self, cdsl: dict[str, Any] | None) -> dict[str, str]:
|
||||||
|
"""Return opaque, head-scoped feature references for pattern contracts."""
|
||||||
|
result: dict[str, str] = {}
|
||||||
|
document_hash = canonical_hash(cdsl) if isinstance(cdsl, dict) else "root"
|
||||||
|
for feature in (cdsl or {}).get("features") or ():
|
||||||
|
if not isinstance(feature, dict) or not isinstance(feature.get("id"), str) or not feature["id"]:
|
||||||
|
continue
|
||||||
|
feature_id = feature["id"]
|
||||||
|
result["ref_" + sha256(f"{document_hash}|{feature_id}".encode("utf-8")).hexdigest()[:16]] = feature_id
|
||||||
|
return result
|
||||||
|
|
||||||
|
def materialize_fragment(self, base_cdsl: dict[str, Any] | None, fragment: dict[str, Any], contract: dict[str, Any], selector_tokens: dict[str, dict[str, Any]], reference_tokens: dict[str, str], *, require_through: bool = False) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||||
|
# ActionCommandHandler validates the exposed schema first, but this
|
||||||
|
# adapter is also used during crash recovery. Keep the runtime boundary
|
||||||
|
# self-contained so a corrupted/replayed staged payload cannot produce
|
||||||
|
# a candidate merely by bypassing that handler-level validation.
|
||||||
|
try:
|
||||||
|
validate_operation_contract(contract)
|
||||||
|
except OperationContractError as error:
|
||||||
|
raise RuntimeAdapterError(f"RUNTIME_CONTRACT_INVALID: {error}") from error
|
||||||
|
current = self.operation_contract(str(contract.get("atomic_id") or ""))
|
||||||
|
if (
|
||||||
|
contract.get("contract_hash") != current["contract_hash"]
|
||||||
|
or contract.get("registry_revision") != current["registry_revision"]
|
||||||
|
):
|
||||||
|
raise RuntimeAdapterError("RUNTIME_CONTRACT_INVALID: operation contract is not the current verified registry entry")
|
||||||
|
selector_shape = str((contract.get("fragment_shape") or {}).get("selector_tokens") or "forbidden")
|
||||||
|
selector_kind = str((contract.get("selector_policy") or {}).get("token_kind") or "")
|
||||||
|
allowed_selectors = [
|
||||||
|
token
|
||||||
|
for token, value in selector_tokens.items()
|
||||||
|
if selector_shape == "required"
|
||||||
|
and isinstance(value, dict)
|
||||||
|
and value.get("kind") == selector_kind
|
||||||
|
]
|
||||||
|
errors = validate_fragment(
|
||||||
|
contract,
|
||||||
|
fragment,
|
||||||
|
selector_tokens=allowed_selectors,
|
||||||
|
reference_tokens=list(reference_tokens),
|
||||||
|
)
|
||||||
|
if errors:
|
||||||
|
raise RuntimeAdapterError(
|
||||||
|
"RUNTIME_PRECONDITION_FAILED: fragment no longer matches the active operation schema: "
|
||||||
|
+ errors[0]["message"]
|
||||||
|
)
|
||||||
|
materialized = deepcopy(fragment)
|
||||||
|
reference = contract["reference_policy"]
|
||||||
|
if reference["mode"] == "snapshot_bound":
|
||||||
|
slot = str(reference["slot"]).removeprefix("params.")
|
||||||
|
supplied = materialized.get("feature", {}).get("params", {}).get(slot, [])
|
||||||
|
if not isinstance(supplied, list) or not all(token in reference_tokens for token in supplied):
|
||||||
|
raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: reference token is absent or stale")
|
||||||
|
materialized["feature"]["params"][slot] = [reference_tokens[token] for token in supplied]
|
||||||
|
self._semantic_preflight(materialized, contract, selector_tokens, base_cdsl, require_through=require_through)
|
||||||
|
document = deepcopy(base_cdsl) if isinstance(base_cdsl, dict) else {
|
||||||
|
"schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part", "part_id": "agent_preflight", "geometry": {"sketches": []}, "features": [],
|
||||||
|
}
|
||||||
|
geometry = document.setdefault("geometry", {})
|
||||||
|
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 RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: base CDSL collections are invalid")
|
||||||
|
index = len(features) + 1
|
||||||
|
feature = materialized["feature"]
|
||||||
|
output = {"id": f"feature_{index:03d}", "atomic_id": contract["atomic_id"], "params": deepcopy(feature["params"]), "depends_on": [str(features[-1].get("id"))] if features else []}
|
||||||
|
if contract["fragment_shape"]["sketch"] == "required":
|
||||||
|
sketch_id = f"sketch_{len(sketches) + 1:03d}"
|
||||||
|
sketch = materialized["sketch"]
|
||||||
|
sketches.append({"id": sketch_id, "workplane": deepcopy(sketch["workplane"]), "profile": deepcopy(sketch["profile"])})
|
||||||
|
output["sketch_id"] = sketch_id
|
||||||
|
selected = [selector_tokens[token]["selector"] for token in feature.get("selector_tokens", [])]
|
||||||
|
slot = contract["selector_policy"]["slot"]
|
||||||
|
if slot == "params.host_face":
|
||||||
|
# A topology selector is authoritative while accepting the
|
||||||
|
# action, but a later pattern replays this feature after its own
|
||||||
|
# cut may have split the selected B-rep face. Lower the accepted
|
||||||
|
# planar selector to a concrete, world-aligned host frame and
|
||||||
|
# transform the author-supplied world coordinates into that
|
||||||
|
# frame. The token remains in the fragment audit for provenance;
|
||||||
|
# the materialized CDSL is stable under replay.
|
||||||
|
output["params"]["host_face"], output["params"]["positions"] = self._materialized_hole_host_frame(
|
||||||
|
selector_tokens[str(feature["selector_tokens"][0])],
|
||||||
|
output["params"].get("positions"),
|
||||||
|
)
|
||||||
|
elif slot == "params.mirror_plane":
|
||||||
|
output["params"]["mirror_plane"] = selected[0]
|
||||||
|
elif slot == "feature.selectors":
|
||||||
|
output["selectors"] = selected
|
||||||
|
features.append(output)
|
||||||
|
try:
|
||||||
|
self._validate_finite_tree(document)
|
||||||
|
self._validate_materialized_runtime_types(output)
|
||||||
|
validate_cdsl(document, self.engine)
|
||||||
|
except Exception as error:
|
||||||
|
message = str(error)
|
||||||
|
# A JSON-schema failure after server materialization is a registry
|
||||||
|
# drift: the author could not have supplied the missing server
|
||||||
|
# field. Engine capability analysis, however, can reject a
|
||||||
|
# schema-valid author sketch (for example an unresolvable analytic
|
||||||
|
# contour). That is a recoverable action precondition failure,
|
||||||
|
# not a deployment defect.
|
||||||
|
if "CDSL engine runtime preflight failed:" in message:
|
||||||
|
raise RuntimeAdapterError(
|
||||||
|
"RUNTIME_PRECONDITION_FAILED: materialized fragment is not executable by the engine: "
|
||||||
|
+ message
|
||||||
|
) from error
|
||||||
|
raise RuntimeAdapterError(
|
||||||
|
"RUNTIME_CONTRACT_INVALID: materialized fragment violates the engine CDSL schema: "
|
||||||
|
+ message
|
||||||
|
) from error
|
||||||
|
return document, {"schema_version": "cad.v3.fragment-audit.v1", "atomic_id": contract["atomic_id"], "fragment_hash": canonical_hash(fragment), "contract_hash": contract["contract_hash"], "assigned_feature_ids": [output["id"]], "assigned_sketch_ids": [output["sketch_id"]] if output.get("sketch_id") else [], "selector_snapshot_id": next(iter(selector_tokens.values()), {}).get("snapshot_id", ""), "selector_tokens": list(feature.get("selector_tokens", [])), "reference_snapshot_id": canonical_hash(reference_tokens), "reference_tokens": list(fragment.get("feature", {}).get("params", {}).get(str(reference.get("slot") or "").removeprefix("params."), [])) if reference["mode"] == "snapshot_bound" else []}
|
||||||
|
|
||||||
|
def rebuild(self, cdsl: dict[str, Any], output_dir: str, task_id: str, revision_id: str) -> dict[str, Any]:
|
||||||
|
root = Path(output_dir)
|
||||||
|
root.mkdir(parents=True, exist_ok=True)
|
||||||
|
cdsl_copy = deepcopy(cdsl)
|
||||||
|
cdsl_copy["part_id"] = task_id
|
||||||
|
step_path = root / "model.step"
|
||||||
|
glb_path = root / "model.glb"
|
||||||
|
self._write_json(root / "model.cdsl.json", cdsl_copy)
|
||||||
|
try:
|
||||||
|
self._validate_finite_tree(cdsl_copy)
|
||||||
|
validate_cdsl(cdsl_copy, self.engine)
|
||||||
|
except RuntimeAdapterError:
|
||||||
|
raise
|
||||||
|
except Exception as error:
|
||||||
|
raise RuntimeAdapterError(f"RUNTIME_PRECONDITION_FAILED: persisted CDSL failed rebuild preflight: {error}") from error
|
||||||
|
try:
|
||||||
|
engine_result = self.engine.run_cdsl_only(cdsl_copy, step_path)
|
||||||
|
if str(engine_result.get("engine") or "") != "cdsl_only":
|
||||||
|
raise RuntimeAdapterError("engine did not execute CDSL-only rebuild")
|
||||||
|
preview = step_to_glb(step_path, glb_path)
|
||||||
|
health = self._health(engine_result, step_path, glb_path)
|
||||||
|
topology = topology_snapshot(engine_result, task_id=task_id, revision_id=revision_id, preview=preview)
|
||||||
|
self._write_json(root / "model.topology.json", topology)
|
||||||
|
manifest = render_checkpoint(self.settings, step_path=step_path, output_dir=root / "renders")
|
||||||
|
report = {"engine_result": engine_result, "preview": preview, "health": health, "render_manifest": manifest}
|
||||||
|
self._write_json(root / "rebuild-report.json", report)
|
||||||
|
return {"health": health, "topology": topology, "report": report, "render_manifest": manifest, "paths": {"cdsl": "model.cdsl.json", "step": "model.step", "glb": "model.glb", "topology": "model.topology.json", "report": "rebuild-report.json", "render_manifest": "renders/render-manifest.json"}}
|
||||||
|
except OSError:
|
||||||
|
# Artifact writes are a recoverable infrastructure outage. Let the
|
||||||
|
# application handler park the same candidate stage for replay.
|
||||||
|
raise
|
||||||
|
except ReviewRenderError as error:
|
||||||
|
raise RuntimeAdapterError(f"RENDER_SERVICE_UNAVAILABLE: {error}") from error
|
||||||
|
except Exception as error:
|
||||||
|
raise RuntimeAdapterError(f"RUNTIME_EXECUTION_FAILURE: {error}") from error
|
||||||
|
|
||||||
|
def _semantic_preflight(self, fragment: dict[str, Any], contract: dict[str, Any], selector_tokens: dict[str, dict[str, Any]], base_cdsl: dict[str, Any] | None, *, require_through: bool) -> None:
|
||||||
|
if fragment.get("feature", {}).get("atomic_id") != contract.get("atomic_id"):
|
||||||
|
raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: atomic_id does not match active contract")
|
||||||
|
policy = contract["selector_policy"]
|
||||||
|
supplied = fragment.get("feature", {}).get("selector_tokens", [])
|
||||||
|
if contract["fragment_shape"]["selector_tokens"] == "required":
|
||||||
|
if not all(token in selector_tokens and selector_tokens[token]["kind"] == policy["token_kind"] for token in supplied):
|
||||||
|
raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: selector token is absent, stale, or has the wrong kind")
|
||||||
|
for name in contract["semantic_preflight"]:
|
||||||
|
self._semantic_preflight_handlers[name](fragment, selector_tokens, base_cdsl, require_through)
|
||||||
|
|
||||||
|
def _preflight_sketch_workplane(self, fragment: dict[str, Any], _selectors: dict[str, dict[str, Any]], _base: dict[str, Any] | None, _require_through: bool) -> None:
|
||||||
|
sketch = fragment.get("sketch")
|
||||||
|
workplane = sketch.get("workplane") if isinstance(sketch, dict) else None
|
||||||
|
if not isinstance(workplane, dict):
|
||||||
|
raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: sketch workplane is unavailable")
|
||||||
|
normal, x_dir = workplane.get("normal"), workplane.get("x_dir")
|
||||||
|
if not isinstance(normal, list) or not isinstance(x_dir, list) or self._norm(normal) <= 1e-9 or self._norm(x_dir) <= 1e-9 or self._norm(self._cross(normal, x_dir)) <= 1e-9:
|
||||||
|
raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: sketch workplane vectors are degenerate")
|
||||||
|
|
||||||
|
def _preflight_profile_non_self_intersecting(self, fragment: dict[str, Any], _selectors: dict[str, dict[str, Any]], _base: dict[str, Any] | None, _require_through: bool) -> None:
|
||||||
|
sketch = fragment.get("sketch")
|
||||||
|
profile = sketch.get("profile") if isinstance(sketch, dict) else None
|
||||||
|
if not isinstance(profile, dict):
|
||||||
|
raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: sketch profile is unavailable")
|
||||||
|
if profile.get("type") == "polygon":
|
||||||
|
vertices = profile.get("vertices")
|
||||||
|
if not isinstance(vertices, list) or len(vertices) < 3 or self._polygon_self_intersects(vertices):
|
||||||
|
raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: polygon profile self-intersects")
|
||||||
|
elif profile.get("type") == "analytic_contours":
|
||||||
|
self._preflight_analytic_contours(profile)
|
||||||
|
|
||||||
|
def _preflight_host_face_exists(self, fragment: dict[str, Any], selectors: dict[str, dict[str, Any]], _base: dict[str, Any] | None, _require_through: bool) -> None:
|
||||||
|
supplied = fragment.get("feature", {}).get("selector_tokens", [])
|
||||||
|
if len(supplied) != 1 or not isinstance(selectors.get(supplied[0]), dict) or selectors[supplied[0]].get("kind") != "face":
|
||||||
|
raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: host face is absent or stale")
|
||||||
|
|
||||||
|
def _materialized_hole_host_frame(
|
||||||
|
self,
|
||||||
|
selected: dict[str, Any],
|
||||||
|
positions: Any,
|
||||||
|
) -> tuple[dict[str, Any], list[dict[str, list[float]]]]:
|
||||||
|
"""Lower a verified planar host selector into replay-stable CDSL data.
|
||||||
|
|
||||||
|
Hole position inputs are world coordinates at the author boundary.
|
||||||
|
The engine's frame form uses local coordinates, so both values must
|
||||||
|
be converted together. Keeping only the selector makes a later
|
||||||
|
pattern replay depend on a face that the source cut has already
|
||||||
|
subdivided, which is neither stable nor geometrically meaningful.
|
||||||
|
"""
|
||||||
|
geometry = selected.get("geometry") if isinstance(selected, dict) else None
|
||||||
|
center = geometry.get("center_mm") if isinstance(geometry, dict) else None
|
||||||
|
normal = geometry.get("normal") if isinstance(geometry, dict) else None
|
||||||
|
if not self._valid_vector3(center) or not self._valid_vector3(normal, require_nonzero=True):
|
||||||
|
raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: selected host face has no usable plane frame")
|
||||||
|
if not isinstance(positions, list) or not positions:
|
||||||
|
raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: hole positions are unavailable for host-frame materialization")
|
||||||
|
origin = [float(value) for value in center]
|
||||||
|
unit_normal = [float(value) / self._norm(normal) for value in normal]
|
||||||
|
seed = [1.0, 0.0, 0.0] if abs(unit_normal[0]) < 0.9 else [0.0, 1.0, 0.0]
|
||||||
|
x_raw = [seed[index] - self._dot(seed, unit_normal) * unit_normal[index] for index in range(3)]
|
||||||
|
x_length = self._norm(x_raw)
|
||||||
|
if x_length <= 1e-9:
|
||||||
|
raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: selected host face cannot define a stable x direction")
|
||||||
|
x_dir = [value / x_length for value in x_raw]
|
||||||
|
y_dir = self._cross(unit_normal, x_dir)
|
||||||
|
local_positions: list[dict[str, list[float]]] = []
|
||||||
|
for position in positions:
|
||||||
|
point = position.get("mm") if isinstance(position, dict) else None
|
||||||
|
if not self._valid_vector3(point):
|
||||||
|
raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: hole position is not a finite 3D point")
|
||||||
|
offset = [float(point[index]) - origin[index] for index in range(3)]
|
||||||
|
local_positions.append({"mm": [
|
||||||
|
self._dot(offset, x_dir),
|
||||||
|
self._dot(offset, y_dir),
|
||||||
|
self._dot(offset, unit_normal),
|
||||||
|
]})
|
||||||
|
return (
|
||||||
|
{"frame": {"origin_mm": origin, "x_dir": x_dir, "y_dir": y_dir, "normal": unit_normal}},
|
||||||
|
local_positions,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _preflight_hole_positions_on_host_plane(self, fragment: dict[str, Any], selectors: dict[str, dict[str, Any]], _base: dict[str, Any] | None, _require_through: bool) -> None:
|
||||||
|
positions = fragment.get("feature", {}).get("params", {}).get("positions")
|
||||||
|
if not isinstance(positions, list) or not positions or not all(isinstance(item, dict) and isinstance(item.get("mm"), list) and len(item["mm"]) == 3 for item in positions):
|
||||||
|
raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: hole positions are invalid for the host plane")
|
||||||
|
supplied = fragment.get("feature", {}).get("selector_tokens", [])
|
||||||
|
host = selectors.get(supplied[0]) if len(supplied) == 1 else None
|
||||||
|
geometry = host.get("geometry") if isinstance(host, dict) else None
|
||||||
|
normal = geometry.get("normal") if isinstance(geometry, dict) else None
|
||||||
|
center = geometry.get("center_mm") if isinstance(geometry, dict) else None
|
||||||
|
bbox = geometry.get("bbox_mm") if isinstance(geometry, dict) else None
|
||||||
|
if not isinstance(geometry, dict) or geometry.get("surface_type") != "plane" or not self._valid_vector3(normal, require_nonzero=True) or not self._valid_vector3(center):
|
||||||
|
raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: hole host must be an observable planar face")
|
||||||
|
unit_normal = [float(component) / self._norm(normal) for component in normal]
|
||||||
|
tolerance = 1e-5
|
||||||
|
if not self._valid_bbox(bbox):
|
||||||
|
raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: host face has no usable topology bounds")
|
||||||
|
for position in positions:
|
||||||
|
point = position["mm"]
|
||||||
|
if not self._valid_vector3(point):
|
||||||
|
raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: hole position is not a finite 3D point")
|
||||||
|
offset = [float(point[index]) - float(center[index]) for index in range(3)]
|
||||||
|
if abs(self._dot(offset, unit_normal)) > tolerance:
|
||||||
|
raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: hole position is not on the selected host plane")
|
||||||
|
if any(float(point[index]) < float(bbox[index]) - tolerance or float(point[index]) > float(bbox[index + 3]) + tolerance for index in range(3)):
|
||||||
|
raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: hole position is outside the selected host-face bounds")
|
||||||
|
boundary_loops = geometry.get("boundary_loops_mm")
|
||||||
|
if isinstance(boundary_loops, list) and boundary_loops and not self._point_in_planar_face(point, boundary_loops, unit_normal, tolerance):
|
||||||
|
raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: hole position is outside the selected host face's effective boundary")
|
||||||
|
|
||||||
|
def _preflight_analytic_contours(self, profile: dict[str, Any]) -> None:
|
||||||
|
contours = profile.get("contours")
|
||||||
|
if not isinstance(contours, list) or not contours:
|
||||||
|
raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: analytic profile requires at least one contour")
|
||||||
|
tolerance = 1e-7
|
||||||
|
for contour_index, contour in enumerate(contours):
|
||||||
|
segments = contour.get("segments") if isinstance(contour, dict) else None
|
||||||
|
if not isinstance(segments, list) or not segments:
|
||||||
|
raise RuntimeAdapterError(f"RUNTIME_PRECONDITION_FAILED: analytic contour {contour_index} is empty")
|
||||||
|
if any(segment.get("type") == "circle" for segment in segments if isinstance(segment, dict)):
|
||||||
|
if len(segments) != 1 or segments[0].get("type") != "circle":
|
||||||
|
raise RuntimeAdapterError(f"RUNTIME_PRECONDITION_FAILED: analytic contour {contour_index} cannot mix a full circle with other segments")
|
||||||
|
continue
|
||||||
|
endpoints: list[tuple[tuple[float, float], tuple[float, float]]] = []
|
||||||
|
for segment_index, segment in enumerate(segments):
|
||||||
|
if not isinstance(segment, dict) or segment.get("type") not in {"line", "arc"}:
|
||||||
|
raise RuntimeAdapterError(f"RUNTIME_PRECONDITION_FAILED: analytic contour {contour_index} has an unsupported segment")
|
||||||
|
start = self._point2(segment.get("start"))
|
||||||
|
end = self._point2(segment.get("end"))
|
||||||
|
if start is None or end is None:
|
||||||
|
raise RuntimeAdapterError(f"RUNTIME_PRECONDITION_FAILED: analytic contour {contour_index} segment {segment_index} has non-finite endpoints")
|
||||||
|
if self._distance2(start, end) <= tolerance:
|
||||||
|
raise RuntimeAdapterError(f"RUNTIME_PRECONDITION_FAILED: analytic contour {contour_index} segment {segment_index} is degenerate")
|
||||||
|
if segment.get("type") == "arc":
|
||||||
|
center = self._point2(segment.get("center"))
|
||||||
|
radius = segment.get("radius_mm")
|
||||||
|
if center is None or not isinstance(radius, (int, float)) or isinstance(radius, bool) or not math.isfinite(float(radius)) or float(radius) <= 0:
|
||||||
|
raise RuntimeAdapterError(f"RUNTIME_PRECONDITION_FAILED: analytic contour {contour_index} arc {segment_index} has an invalid circle")
|
||||||
|
arc_tolerance = max(tolerance, float(radius) * 1e-7)
|
||||||
|
if abs(self._distance2(start, center) - float(radius)) > arc_tolerance or abs(self._distance2(end, center) - float(radius)) > arc_tolerance:
|
||||||
|
raise RuntimeAdapterError(f"RUNTIME_PRECONDITION_FAILED: analytic contour {contour_index} arc {segment_index} endpoints are not on the declared circle")
|
||||||
|
endpoints.append((start, end))
|
||||||
|
for segment_index in range(1, len(endpoints)):
|
||||||
|
if self._distance2(endpoints[segment_index - 1][1], endpoints[segment_index][0]) > tolerance:
|
||||||
|
raise RuntimeAdapterError(f"RUNTIME_PRECONDITION_FAILED: analytic contour {contour_index} is discontinuous before segment {segment_index}")
|
||||||
|
if contour.get("closed") is True and self._distance2(endpoints[-1][1], endpoints[0][0]) > tolerance:
|
||||||
|
raise RuntimeAdapterError(f"RUNTIME_PRECONDITION_FAILED: analytic contour {contour_index} is not closed")
|
||||||
|
line_segments = [segment for segment, raw in zip(endpoints, segments) if raw.get("type") == "line"]
|
||||||
|
for first, (a, b) in enumerate(line_segments):
|
||||||
|
for second, (c, d) in enumerate(line_segments):
|
||||||
|
if second <= first + 1 or (first == 0 and second == len(line_segments) - 1 and contour.get("closed") is True):
|
||||||
|
continue
|
||||||
|
if self._segments_intersect(a, b, c, d):
|
||||||
|
raise RuntimeAdapterError(f"RUNTIME_PRECONDITION_FAILED: analytic contour {contour_index} has an obvious self-intersection")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _validate_materialized_runtime_types(cls, feature: dict[str, Any]) -> None:
|
||||||
|
atomic_id = str(feature.get("atomic_id") or "")
|
||||||
|
if not atomic_id.startswith("hole_"):
|
||||||
|
return
|
||||||
|
params = feature.get("params") if isinstance(feature.get("params"), dict) else {}
|
||||||
|
host = params.get("host_face") if isinstance(params.get("host_face"), dict) else None
|
||||||
|
frame = host.get("frame") if isinstance(host, dict) and isinstance(host.get("frame"), dict) else None
|
||||||
|
if not isinstance(frame, dict):
|
||||||
|
raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: materialized hole host_face.frame is unavailable")
|
||||||
|
for field in ("origin_mm", "x_dir", "y_dir", "normal"):
|
||||||
|
if not cls._valid_vector3(frame.get(field), require_nonzero=field != "origin_mm"):
|
||||||
|
raise RuntimeAdapterError(f"RUNTIME_PRECONDITION_FAILED: materialized hole host_face.frame.{field} is invalid")
|
||||||
|
x_dir, y_dir, normal = frame["x_dir"], frame["y_dir"], frame["normal"]
|
||||||
|
if abs(cls._dot(x_dir, y_dir)) > 1e-6 or abs(cls._dot(x_dir, normal)) > 1e-6 or abs(cls._dot(y_dir, normal)) > 1e-6:
|
||||||
|
raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: materialized hole host frame is not orthogonal")
|
||||||
|
positions = params.get("positions")
|
||||||
|
if not isinstance(positions, list) or not positions:
|
||||||
|
raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: materialized hole positions are unavailable")
|
||||||
|
for index, position in enumerate(positions):
|
||||||
|
point = position.get("mm") if isinstance(position, dict) else None
|
||||||
|
if not cls._valid_vector3(point) or abs(float(point[2])) > 1e-5:
|
||||||
|
raise RuntimeAdapterError(f"RUNTIME_PRECONDITION_FAILED: materialized hole position {index} is invalid in the host frame")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _validate_finite_tree(value: Any, path: str = "$") -> None:
|
||||||
|
if value is None:
|
||||||
|
raise RuntimeAdapterError(f"RUNTIME_PRECONDITION_FAILED: {path} must not be null")
|
||||||
|
if isinstance(value, float) and not math.isfinite(value):
|
||||||
|
raise RuntimeAdapterError(f"RUNTIME_PRECONDITION_FAILED: {path} must be finite")
|
||||||
|
if isinstance(value, dict):
|
||||||
|
for key, child in value.items():
|
||||||
|
ProfileCadRuntime._validate_finite_tree(child, f"{path}.{key}")
|
||||||
|
elif isinstance(value, list):
|
||||||
|
for index, child in enumerate(value):
|
||||||
|
ProfileCadRuntime._validate_finite_tree(child, f"{path}[{index}]")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _point2(value: Any) -> tuple[float, float] | None:
|
||||||
|
if not isinstance(value, list) or len(value) != 2:
|
||||||
|
return None
|
||||||
|
if not all(isinstance(item, (int, float)) and not isinstance(item, bool) and math.isfinite(float(item)) for item in value):
|
||||||
|
return None
|
||||||
|
return float(value[0]), float(value[1])
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _distance2(left: tuple[float, float], right: tuple[float, float]) -> float:
|
||||||
|
return math.hypot(left[0] - right[0], left[1] - right[1])
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _point_in_planar_face(cls, point: list[float], loops: list[Any], normal: list[float], tolerance: float) -> bool:
|
||||||
|
drop_axis = max(range(3), key=lambda index: abs(float(normal[index])))
|
||||||
|
|
||||||
|
def project(value: Any) -> tuple[float, float] | None:
|
||||||
|
if not cls._valid_vector3(value):
|
||||||
|
return None
|
||||||
|
coordinates = [float(value[index]) for index in range(3) if index != drop_axis]
|
||||||
|
return coordinates[0], coordinates[1]
|
||||||
|
|
||||||
|
projected_point = project(point)
|
||||||
|
if projected_point is None:
|
||||||
|
return False
|
||||||
|
polygons: list[list[tuple[float, float]]] = []
|
||||||
|
for loop in loops:
|
||||||
|
polygon = [project(vertex) for vertex in loop] if isinstance(loop, list) else []
|
||||||
|
if len(polygon) >= 3 and all(vertex is not None for vertex in polygon):
|
||||||
|
polygons.append([vertex for vertex in polygon if vertex is not None])
|
||||||
|
if not polygons:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def area(polygon: list[tuple[float, float]]) -> float:
|
||||||
|
return abs(sum(
|
||||||
|
polygon[index][0] * polygon[(index + 1) % len(polygon)][1]
|
||||||
|
- polygon[(index + 1) % len(polygon)][0] * polygon[index][1]
|
||||||
|
for index in range(len(polygon))
|
||||||
|
)) / 2
|
||||||
|
|
||||||
|
def contains(polygon: list[tuple[float, float]]) -> bool:
|
||||||
|
x, y = projected_point
|
||||||
|
inside = False
|
||||||
|
for index, first in enumerate(polygon):
|
||||||
|
second = polygon[(index + 1) % len(polygon)]
|
||||||
|
if cls._distance_to_segment_2d(projected_point, first, second) <= tolerance:
|
||||||
|
return True
|
||||||
|
if (first[1] > y) != (second[1] > y):
|
||||||
|
crossing_x = (second[0] - first[0]) * (y - first[1]) / (second[1] - first[1]) + first[0]
|
||||||
|
if x < crossing_x:
|
||||||
|
inside = not inside
|
||||||
|
return inside
|
||||||
|
|
||||||
|
outer = max(polygons, key=area)
|
||||||
|
return contains(outer) and not any(contains(inner) for inner in polygons if inner is not outer)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _distance_to_segment_2d(point: tuple[float, float], start: tuple[float, float], end: tuple[float, float]) -> float:
|
||||||
|
dx, dy = end[0] - start[0], end[1] - start[1]
|
||||||
|
length_squared = dx * dx + dy * dy
|
||||||
|
if length_squared <= 1e-18:
|
||||||
|
return math.hypot(point[0] - start[0], point[1] - start[1])
|
||||||
|
fraction = max(0.0, min(1.0, ((point[0] - start[0]) * dx + (point[1] - start[1]) * dy) / length_squared))
|
||||||
|
return math.hypot(point[0] - (start[0] + fraction * dx), point[1] - (start[1] + fraction * dy))
|
||||||
|
|
||||||
|
def _preflight_cut_exit_distance(self, fragment: dict[str, Any], selectors: dict[str, dict[str, Any]], _base: dict[str, Any] | None, require_through: bool) -> None:
|
||||||
|
if not require_through:
|
||||||
|
return
|
||||||
|
params = fragment.get("feature", {}).get("params", {})
|
||||||
|
depth = params.get("depth_mm", params.get("distance_mm"))
|
||||||
|
supplied = fragment.get("feature", {}).get("selector_tokens", [])
|
||||||
|
if not isinstance(depth, (int, float)):
|
||||||
|
raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: cut depth is unavailable")
|
||||||
|
if supplied:
|
||||||
|
host = selectors[supplied[0]]["geometry"]
|
||||||
|
bbox = host.get("bbox_mm") if isinstance(host, dict) else None
|
||||||
|
normal = host.get("normal") or host.get("plane_normal") if isinstance(host, dict) else None
|
||||||
|
thickness = self._span_from_bbox(bbox, normal)
|
||||||
|
else:
|
||||||
|
bbox = self._active_body_bbox(selectors)
|
||||||
|
normal = (fragment.get("sketch") or {}).get("workplane", {}).get("normal")
|
||||||
|
thickness = self._span_from_bbox(bbox, normal)
|
||||||
|
if thickness is None:
|
||||||
|
raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: cannot prove through-cut exit distance from the active topology")
|
||||||
|
if float(depth) <= thickness + 1e-6:
|
||||||
|
raise RuntimeAdapterError(
|
||||||
|
"RUNTIME_PRECONDITION_FAILED: "
|
||||||
|
f"cut depth {float(depth):g} mm must exceed measured host-body thickness {thickness:g} mm"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _preflight_requires_active_solid(self, _fragment: dict[str, Any], _selectors: dict[str, dict[str, Any]], base: dict[str, Any] | None, _require_through: bool) -> None:
|
||||||
|
# A committed active CDSL document exists only after a candidate with
|
||||||
|
# a solid has been accepted. Reference and subtraction operations
|
||||||
|
# therefore cannot be the first feature of a part.
|
||||||
|
if not isinstance(base, dict) or not base.get("features"):
|
||||||
|
raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: operation requires an active solid checkpoint")
|
||||||
|
|
||||||
|
def _preflight_revolve_axis_on_sketch(self, fragment: dict[str, Any], _selectors: dict[str, dict[str, Any]], _base: dict[str, Any] | None, _require_through: bool) -> None:
|
||||||
|
axis = fragment.get("feature", {}).get("params", {}).get("axis")
|
||||||
|
direction = axis.get("direction") if isinstance(axis, dict) else None
|
||||||
|
origin = axis.get("origin_mm") if isinstance(axis, dict) else None
|
||||||
|
workplane = fragment.get("sketch", {}).get("workplane") if isinstance(fragment.get("sketch"), dict) else None
|
||||||
|
plane_origin = workplane.get("origin_mm") if isinstance(workplane, dict) else None
|
||||||
|
plane_normal = workplane.get("normal") if isinstance(workplane, dict) else None
|
||||||
|
if not self._valid_vector3(direction, require_nonzero=True) or not self._valid_vector3(origin) or not self._valid_vector3(plane_origin) or not self._valid_vector3(plane_normal, require_nonzero=True):
|
||||||
|
raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: revolve axis or sketch plane is invalid")
|
||||||
|
unit_direction = [float(component) / self._norm(direction) for component in direction]
|
||||||
|
unit_normal = [float(component) / self._norm(plane_normal) for component in plane_normal]
|
||||||
|
if abs(self._dot(unit_direction, unit_normal)) > 1e-6:
|
||||||
|
raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: revolve axis is not parallel to the sketch plane")
|
||||||
|
axis_offset = [float(origin[index]) - float(plane_origin[index]) for index in range(3)]
|
||||||
|
if abs(self._dot(axis_offset, unit_normal)) > 1e-5:
|
||||||
|
raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: revolve axis is not on the sketch plane")
|
||||||
|
|
||||||
|
def _preflight_reference_plane_nonzero_normal(self, fragment: dict[str, Any], _selectors: dict[str, dict[str, Any]], _base: dict[str, Any] | None, _require_through: bool) -> None:
|
||||||
|
plane = fragment.get("feature", {}).get("params", {}).get("plane")
|
||||||
|
normal = plane.get("normal") if isinstance(plane, dict) else None
|
||||||
|
if not isinstance(normal, list) or self._norm(normal) <= 1e-9:
|
||||||
|
raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: reference plane normal is degenerate")
|
||||||
|
|
||||||
|
def _preflight_reference_axis_nonzero_direction(self, fragment: dict[str, Any], _selectors: dict[str, dict[str, Any]], _base: dict[str, Any] | None, _require_through: bool) -> None:
|
||||||
|
axis = fragment.get("feature", {}).get("params", {}).get("axis")
|
||||||
|
direction = axis.get("direction") if isinstance(axis, dict) else None
|
||||||
|
if not isinstance(direction, list) or self._norm(direction) <= 1e-9:
|
||||||
|
raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: reference axis direction is degenerate")
|
||||||
|
|
||||||
|
def _preflight_selected_edges_exist(self, fragment: dict[str, Any], selectors: dict[str, dict[str, Any]], _base: dict[str, Any] | None, _require_through: bool) -> None:
|
||||||
|
supplied = fragment.get("feature", {}).get("selector_tokens", [])
|
||||||
|
if not supplied or not all(isinstance(selectors.get(token), dict) and selectors[token].get("kind") == "edge" for token in supplied):
|
||||||
|
raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: selected edge is absent or stale")
|
||||||
|
|
||||||
|
def _preflight_source_features_exist(self, fragment: dict[str, Any], _selectors: dict[str, dict[str, Any]], base: dict[str, Any] | None, _require_through: bool) -> None:
|
||||||
|
params = fragment.get("feature", {}).get("params", {})
|
||||||
|
known = {str(feature.get("id") or "") for feature in ((base or {}).get("features") or ()) if isinstance(feature, dict)}
|
||||||
|
if not set(params.get("source_feature_ids") or ()).issubset(known):
|
||||||
|
raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: source feature is not part of current head")
|
||||||
|
|
||||||
|
def _preflight_mirror_plane_exists(self, fragment: dict[str, Any], selectors: dict[str, dict[str, Any]], _base: dict[str, Any] | None, _require_through: bool) -> None:
|
||||||
|
supplied = fragment.get("feature", {}).get("selector_tokens", [])
|
||||||
|
if len(supplied) != 1 or not isinstance(selectors.get(supplied[0]), dict) or selectors[supplied[0]].get("kind") != "plane":
|
||||||
|
raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: mirror plane is absent or stale")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _polygon_self_intersects(vertices: list[Any]) -> bool:
|
||||||
|
points = [(float(point[0]), float(point[1])) for point in vertices if isinstance(point, list) and len(point) == 2]
|
||||||
|
if len(points) != len(vertices):
|
||||||
|
return True
|
||||||
|
segments = list(zip(points, [*points[1:], points[0]]))
|
||||||
|
for first, (a, b) in enumerate(segments):
|
||||||
|
for second, (c, d) in enumerate(segments):
|
||||||
|
if second <= first + 1 or (first == 0 and second == len(segments) - 1):
|
||||||
|
continue
|
||||||
|
if ProfileCadRuntime._segments_intersect(a, b, c, d):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _segments_intersect(a: tuple[float, float], b: tuple[float, float], c: tuple[float, float], d: tuple[float, float]) -> bool:
|
||||||
|
def orientation(p: tuple[float, float], q: tuple[float, float], r: tuple[float, float]) -> float:
|
||||||
|
return (q[0] - p[0]) * (r[1] - p[1]) - (q[1] - p[1]) * (r[0] - p[0])
|
||||||
|
|
||||||
|
left = orientation(a, b, c)
|
||||||
|
right = orientation(a, b, d)
|
||||||
|
low = orientation(c, d, a)
|
||||||
|
high = orientation(c, d, b)
|
||||||
|
return (left > 0) != (right > 0) and (low > 0) != (high > 0)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _active_body_bbox(selector_tokens: dict[str, dict[str, Any]]) -> list[float] | None:
|
||||||
|
for token in selector_tokens.values():
|
||||||
|
if token.get("kind") != "body":
|
||||||
|
continue
|
||||||
|
geometry = token.get("geometry")
|
||||||
|
bbox = geometry.get("bbox_mm") if isinstance(geometry, dict) else None
|
||||||
|
if isinstance(bbox, list) and len(bbox) == 6 and all(isinstance(value, (int, float)) for value in bbox):
|
||||||
|
return [float(value) for value in bbox]
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _span_from_bbox(bbox: Any, normal: Any) -> float | None:
|
||||||
|
if not isinstance(bbox, list) or len(bbox) != 6 or not isinstance(normal, list) or len(normal) != 3:
|
||||||
|
return None
|
||||||
|
if not all(isinstance(value, (int, float)) for value in [*bbox, *normal]):
|
||||||
|
return None
|
||||||
|
length = math.sqrt(sum(float(value) ** 2 for value in normal))
|
||||||
|
if length <= 1e-9:
|
||||||
|
return None
|
||||||
|
return sum(
|
||||||
|
abs(float(normal[index]) / length) * abs(float(bbox[index + 3]) - float(bbox[index]))
|
||||||
|
for index in range(3)
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _norm(vector: list[float]) -> float:
|
||||||
|
return math.sqrt(sum(float(item) ** 2 for item in vector))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _dot(left: list[float], right: list[float]) -> float:
|
||||||
|
return sum(float(left[index]) * float(right[index]) for index in range(3))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _valid_vector3(value: Any, *, require_nonzero: bool = False) -> bool:
|
||||||
|
valid = (
|
||||||
|
isinstance(value, list)
|
||||||
|
and len(value) == 3
|
||||||
|
and all(isinstance(item, (int, float)) and not isinstance(item, bool) and math.isfinite(float(item)) for item in value)
|
||||||
|
)
|
||||||
|
return valid and (not require_nonzero or ProfileCadRuntime._norm(value) > 1e-9)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _valid_bbox(value: Any) -> bool:
|
||||||
|
return (
|
||||||
|
isinstance(value, list)
|
||||||
|
and len(value) == 6
|
||||||
|
and all(isinstance(item, (int, float)) and not isinstance(item, bool) and math.isfinite(float(item)) for item in value)
|
||||||
|
and all(float(value[index]) <= float(value[index + 3]) for index in range(3))
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _cross(a: list[float], b: list[float]) -> list[float]:
|
||||||
|
return [float(a[1]) * float(b[2]) - float(a[2]) * float(b[1]), float(a[2]) * float(b[0]) - float(a[0]) * float(b[2]), float(a[0]) * float(b[1]) - float(a[1]) * float(b[0])]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _write_json(path: Path, payload: dict[str, Any]) -> None:
|
||||||
|
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _health(engine_result: dict[str, Any], step_path: Path, glb_path: Path) -> dict[str, Any]:
|
||||||
|
bbox = engine_result.get("bbox_mm") if isinstance(engine_result.get("bbox_mm"), dict) else {}
|
||||||
|
minimum, maximum = bbox.get("min"), bbox.get("max")
|
||||||
|
if not isinstance(minimum, list) or not isinstance(maximum, list) or len(minimum) != 3 or len(maximum) != 3 or not step_path.is_file() or not glb_path.is_file():
|
||||||
|
raise RuntimeAdapterError("rebuild did not produce complete deterministic artifacts")
|
||||||
|
return {"bbox_mm": {"min": [float(item) for item in minimum], "max": [float(item) for item in maximum], "dimensions": [float(maximum[index]) - float(minimum[index]) for index in range(3)]}, "volume_mm3": float(engine_result["volume_mm3"]), "solid_count": int(engine_result["solid_count"]), "feature_count": len(engine_result.get("feature_results") or [])}
|
||||||
@@ -0,0 +1,414 @@
|
|||||||
|
"""SQLite repository: the sole mutable state authority for protocol v3."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from contextlib import contextmanager
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import sqlite3
|
||||||
|
from threading import RLock
|
||||||
|
from typing import Any, Iterator
|
||||||
|
|
||||||
|
from app.cad_agent.domain.errors import ErrorCode
|
||||||
|
from app.cad_agent.domain.state import PendingAction, TaskPhase, TaskState
|
||||||
|
from app.cad_agent.ports import InvocationRecord
|
||||||
|
|
||||||
|
|
||||||
|
class SqliteTaskRepository:
|
||||||
|
def __init__(self, database_path: Path) -> None:
|
||||||
|
self.database_path = database_path
|
||||||
|
self.database_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
self._lock = RLock()
|
||||||
|
self._initialize()
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def _connection(self) -> Iterator[sqlite3.Connection]:
|
||||||
|
connection = sqlite3.connect(self.database_path, timeout=20, isolation_level=None)
|
||||||
|
try:
|
||||||
|
connection.row_factory = sqlite3.Row
|
||||||
|
connection.execute("PRAGMA foreign_keys = ON")
|
||||||
|
connection.execute("PRAGMA journal_mode = WAL")
|
||||||
|
yield connection
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
def _initialize(self) -> None:
|
||||||
|
with self._lock, self._connection() as connection:
|
||||||
|
connection.executescript(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS tasks (
|
||||||
|
task_id TEXT PRIMARY KEY,
|
||||||
|
protocol_version TEXT NOT NULL CHECK(protocol_version = '3.0'),
|
||||||
|
request TEXT NOT NULL,
|
||||||
|
phase TEXT NOT NULL,
|
||||||
|
state_version INTEGER NOT NULL,
|
||||||
|
active_revision TEXT NOT NULL DEFAULT '',
|
||||||
|
pending_action_json TEXT,
|
||||||
|
candidate_id TEXT NOT NULL DEFAULT '',
|
||||||
|
candidate_stage_id TEXT NOT NULL DEFAULT '',
|
||||||
|
repair_required INTEGER NOT NULL DEFAULT 0,
|
||||||
|
last_error TEXT,
|
||||||
|
retry_from_phase TEXT NOT NULL DEFAULT '',
|
||||||
|
requirements_draft_path TEXT NOT NULL DEFAULT '',
|
||||||
|
requirements_review_path TEXT NOT NULL DEFAULT '',
|
||||||
|
requirements_contract_path TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS invocations (
|
||||||
|
invocation_id TEXT PRIMARY KEY,
|
||||||
|
task_id TEXT NOT NULL REFERENCES tasks(task_id),
|
||||||
|
idempotency_key TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
result_json TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(task_id, idempotency_key)
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS ledger (
|
||||||
|
sequence INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
task_id TEXT NOT NULL REFERENCES tasks(task_id),
|
||||||
|
event_json TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS outbox (
|
||||||
|
event_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
task_id TEXT NOT NULL REFERENCES tasks(task_id),
|
||||||
|
event_json TEXT NOT NULL,
|
||||||
|
published_at TEXT
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS usage_records (
|
||||||
|
usage_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
task_id TEXT NOT NULL REFERENCES tasks(task_id),
|
||||||
|
usage_json TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS tool_audits (
|
||||||
|
audit_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
task_id TEXT NOT NULL REFERENCES tasks(task_id),
|
||||||
|
audit_json TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS model_capabilities (
|
||||||
|
provider_id TEXT NOT NULL,
|
||||||
|
model_id TEXT NOT NULL,
|
||||||
|
schema_hash TEXT NOT NULL,
|
||||||
|
supported INTEGER NOT NULL,
|
||||||
|
report_json TEXT NOT NULL,
|
||||||
|
checked_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY(provider_id, model_id, schema_hash)
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
columns = {str(row["name"]) for row in connection.execute("PRAGMA table_info(tasks)").fetchall()}
|
||||||
|
if "requirements_draft_path" not in columns:
|
||||||
|
connection.execute("ALTER TABLE tasks ADD COLUMN requirements_draft_path TEXT NOT NULL DEFAULT ''")
|
||||||
|
if "requirements_review_path" not in columns:
|
||||||
|
connection.execute("ALTER TABLE tasks ADD COLUMN requirements_review_path TEXT NOT NULL DEFAULT ''")
|
||||||
|
if "requirements_contract_path" not in columns:
|
||||||
|
connection.execute("ALTER TABLE tasks ADD COLUMN requirements_contract_path TEXT NOT NULL DEFAULT ''")
|
||||||
|
if "retry_from_phase" not in columns:
|
||||||
|
connection.execute("ALTER TABLE tasks ADD COLUMN retry_from_phase TEXT NOT NULL DEFAULT ''")
|
||||||
|
|
||||||
|
def create_task(self, task_id: str, request: str) -> TaskState:
|
||||||
|
with self._lock, self._connection() as connection:
|
||||||
|
connection.execute(
|
||||||
|
"INSERT OR IGNORE INTO tasks(task_id, protocol_version, request, phase, state_version) VALUES (?, '3.0', ?, ?, 0)",
|
||||||
|
(task_id, request, TaskPhase.DRAFTING_REQUIREMENTS.value),
|
||||||
|
)
|
||||||
|
state = self.get_state(task_id)
|
||||||
|
if state is None:
|
||||||
|
raise RuntimeError("SQLite task insert was not visible")
|
||||||
|
return state
|
||||||
|
|
||||||
|
def get_state(self, task_id: str) -> TaskState | None:
|
||||||
|
with self._lock, self._connection() as connection:
|
||||||
|
row = connection.execute("SELECT * FROM tasks WHERE task_id = ?", (task_id,)).fetchone()
|
||||||
|
return self._state(row) if row is not None else None
|
||||||
|
|
||||||
|
def get_task_projection(self, task_id: str) -> dict[str, Any] | None:
|
||||||
|
state = self.get_state(task_id)
|
||||||
|
if state is None:
|
||||||
|
return None
|
||||||
|
events = self.ledger_events(task_id)
|
||||||
|
revisions = [
|
||||||
|
{
|
||||||
|
"revision_id": item["revision_id"], "status": "success", "visibility": "final" if state.phase == TaskPhase.COMPLETED and item["revision_id"] == state.active_revision else "checkpoint",
|
||||||
|
"cdsl_path": f"revisions/{item['revision_id']}/model.cdsl.json", "step_path": f"revisions/{item['revision_id']}/model.step", "glb_path": f"revisions/{item['revision_id']}/model.glb", "report_path": f"revisions/{item['revision_id']}/rebuild-report.json", "candidate_review_path": item.get("review_path", ""),
|
||||||
|
}
|
||||||
|
for item in events
|
||||||
|
if item.get("event") == "accepted" and isinstance(item.get("revision_id"), str)
|
||||||
|
]
|
||||||
|
frozen = next((item for item in reversed(events) if item.get("event") == "requirements_contract_frozen"), {})
|
||||||
|
verification_warnings = [
|
||||||
|
str(item) for item in frozen.get("verification_warnings") or () if str(item)
|
||||||
|
] if isinstance(frozen, dict) else []
|
||||||
|
applied_normalizations = [
|
||||||
|
item for item in frozen.get("applied_normalizations") or () if isinstance(item, dict)
|
||||||
|
] if isinstance(frozen, dict) else []
|
||||||
|
status_event = next((
|
||||||
|
item for item in reversed(events)
|
||||||
|
if item.get("event") in {
|
||||||
|
"requirements_waiting_for_user", "waiting_retry", "call_budget_exhausted",
|
||||||
|
"requirements_review_limit_reached", "no_progress_limit",
|
||||||
|
"candidate_runtime_execution_failure", "candidate_recovery_runtime_execution_failure",
|
||||||
|
"failed_author_format", "runtime_contract_invalid",
|
||||||
|
}
|
||||||
|
), {}) if state.phase in {TaskPhase.FAILED, TaskPhase.WAITING_RETRY, TaskPhase.WAITING_FOR_USER} else {}
|
||||||
|
questions = [str(item) for item in status_event.get("questions") or () if str(item)] if isinstance(status_event, dict) else []
|
||||||
|
findings = [item for item in status_event.get("findings") or () if isinstance(item, dict)] if isinstance(status_event, dict) else []
|
||||||
|
issues = [str(item.get("description") or "") for item in findings if item.get("description")]
|
||||||
|
return {
|
||||||
|
"schema_version": "3.0",
|
||||||
|
"task_id": state.task_id,
|
||||||
|
"phase": state.phase.value,
|
||||||
|
"lifecycle": self._lifecycle(state.phase),
|
||||||
|
"state_version": state.version,
|
||||||
|
"active_revision": state.active_revision,
|
||||||
|
"current_revision": state.active_revision,
|
||||||
|
"published_revision": state.active_revision if state.phase == TaskPhase.COMPLETED else "",
|
||||||
|
"pending_action": self._pending_payload(state.pending_action),
|
||||||
|
"active_candidate_id": state.candidate_id,
|
||||||
|
"repair_required": state.repair_required,
|
||||||
|
"last_error": state.last_error.value if state.last_error else "",
|
||||||
|
"retry_from_phase": state.retry_from_phase.value if state.retry_from_phase else "",
|
||||||
|
"requirements_draft_path": state.requirements_draft_path,
|
||||||
|
"requirements_review_path": state.requirements_review_path,
|
||||||
|
"requirements_contract_path": state.requirements_contract_path,
|
||||||
|
"verification_status": "completed_with_risks" if verification_warnings else "verified",
|
||||||
|
"verification_warnings": verification_warnings,
|
||||||
|
"applied_normalizations": applied_normalizations,
|
||||||
|
"message": str(status_event.get("message") or "") if isinstance(status_event, dict) else "",
|
||||||
|
"questions": questions,
|
||||||
|
"issues": issues,
|
||||||
|
"blocker_type": (
|
||||||
|
"requirements_ambiguity" if state.phase == TaskPhase.WAITING_FOR_USER
|
||||||
|
else str(status_event.get("event") or "") if isinstance(status_event, dict) else ""
|
||||||
|
),
|
||||||
|
"user_action_required": state.phase == TaskPhase.WAITING_FOR_USER and bool(questions),
|
||||||
|
"action_ledger_summary": events[-12:],
|
||||||
|
"revisions": revisions,
|
||||||
|
}
|
||||||
|
|
||||||
|
def ledger_events(self, task_id: str) -> list[dict[str, Any]]:
|
||||||
|
with self._lock, self._connection() as connection:
|
||||||
|
rows = connection.execute("SELECT sequence, event_json, created_at FROM ledger WHERE task_id = ? ORDER BY sequence", (task_id,)).fetchall()
|
||||||
|
return [{"sequence": row["sequence"], "at": row["created_at"], **json.loads(row["event_json"])} for row in rows]
|
||||||
|
|
||||||
|
def invocation_records(self, task_id: str) -> list[dict[str, Any]]:
|
||||||
|
with self._lock, self._connection() as connection:
|
||||||
|
rows = connection.execute(
|
||||||
|
"SELECT invocation_id, idempotency_key, status, result_json, created_at, updated_at FROM invocations WHERE task_id = ? ORDER BY created_at, invocation_id",
|
||||||
|
(task_id,),
|
||||||
|
).fetchall()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"invocation_id": str(row["invocation_id"]), "idempotency_key": str(row["idempotency_key"]),
|
||||||
|
"status": str(row["status"]), "result": json.loads(row["result_json"]) if row["result_json"] else None,
|
||||||
|
"created_at": str(row["created_at"]), "updated_at": str(row["updated_at"]),
|
||||||
|
}
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
def compare_and_swap(
|
||||||
|
self,
|
||||||
|
state: TaskState,
|
||||||
|
*,
|
||||||
|
events: list[dict[str, Any]] = (),
|
||||||
|
invocation_id: str | None = None,
|
||||||
|
invocation_result: dict[str, Any] | None = None,
|
||||||
|
) -> bool:
|
||||||
|
if (invocation_id is None) != (invocation_result is None):
|
||||||
|
raise ValueError("Invocation completion requires both invocation_id and invocation_result")
|
||||||
|
previous_version = state.version - 1
|
||||||
|
if previous_version < 0:
|
||||||
|
raise ValueError("State version must advance exactly once")
|
||||||
|
with self._lock, self._connection() as connection:
|
||||||
|
connection.execute("BEGIN IMMEDIATE")
|
||||||
|
try:
|
||||||
|
cursor = connection.execute(
|
||||||
|
"""UPDATE tasks SET phase = ?, state_version = ?, active_revision = ?, pending_action_json = ?,
|
||||||
|
candidate_id = ?, candidate_stage_id = ?, repair_required = ?, last_error = ?, retry_from_phase = ?, requirements_draft_path = ?,
|
||||||
|
requirements_review_path = ?, requirements_contract_path = ?, updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE task_id = ? AND state_version = ?""",
|
||||||
|
(
|
||||||
|
state.phase.value, state.version, state.active_revision,
|
||||||
|
json.dumps(self._pending_payload(state.pending_action), ensure_ascii=True) if state.pending_action else None,
|
||||||
|
state.candidate_id, state.candidate_stage_id,
|
||||||
|
int(state.repair_required), state.last_error.value if state.last_error else None,
|
||||||
|
state.retry_from_phase.value if state.retry_from_phase else "", state.requirements_draft_path,
|
||||||
|
state.requirements_review_path, state.requirements_contract_path,
|
||||||
|
state.task_id, previous_version,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if cursor.rowcount != 1:
|
||||||
|
connection.execute("ROLLBACK")
|
||||||
|
return False
|
||||||
|
for event in events:
|
||||||
|
encoded = json.dumps(event, ensure_ascii=True, sort_keys=True)
|
||||||
|
connection.execute("INSERT INTO ledger(task_id, event_json) VALUES (?, ?)", (state.task_id, encoded))
|
||||||
|
connection.execute("INSERT INTO outbox(task_id, event_json) VALUES (?, ?)", (state.task_id, encoded))
|
||||||
|
if invocation_id is not None and invocation_result is not None:
|
||||||
|
finished = connection.execute(
|
||||||
|
"""UPDATE invocations
|
||||||
|
SET status = 'finished', result_json = ?, updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE invocation_id = ? AND task_id = ? AND status = 'processing'""",
|
||||||
|
(json.dumps(invocation_result, ensure_ascii=True), invocation_id, state.task_id),
|
||||||
|
)
|
||||||
|
if finished.rowcount != 1:
|
||||||
|
raise ValueError("Invocation is not an active record for this state transition")
|
||||||
|
connection.execute("COMMIT")
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
connection.execute("ROLLBACK")
|
||||||
|
raise
|
||||||
|
|
||||||
|
def begin_invocation(self, task_id: str, invocation_id: str, idempotency_key: str) -> InvocationRecord:
|
||||||
|
with self._lock, self._connection() as connection:
|
||||||
|
connection.execute("BEGIN IMMEDIATE")
|
||||||
|
try:
|
||||||
|
existing = connection.execute("SELECT * FROM invocations WHERE task_id = ? AND idempotency_key = ?", (task_id, idempotency_key)).fetchone()
|
||||||
|
if existing is not None:
|
||||||
|
connection.execute("COMMIT")
|
||||||
|
return self._invocation(existing)
|
||||||
|
connection.execute("INSERT INTO invocations(invocation_id, task_id, idempotency_key, status) VALUES (?, ?, ?, 'processing')", (invocation_id, task_id, idempotency_key))
|
||||||
|
connection.execute("COMMIT")
|
||||||
|
return InvocationRecord(invocation_id, idempotency_key, "processing")
|
||||||
|
except Exception:
|
||||||
|
connection.execute("ROLLBACK")
|
||||||
|
raise
|
||||||
|
|
||||||
|
def get_invocation(self, task_id: str, invocation_id: str) -> InvocationRecord | None:
|
||||||
|
with self._lock, self._connection() as connection:
|
||||||
|
row = connection.execute(
|
||||||
|
"SELECT * FROM invocations WHERE task_id = ? AND invocation_id = ?",
|
||||||
|
(task_id, invocation_id),
|
||||||
|
).fetchone()
|
||||||
|
return self._invocation(row) if row is not None else None
|
||||||
|
|
||||||
|
def finish_invocation(self, invocation_id: str, result: dict[str, Any]) -> None:
|
||||||
|
with self._lock, self._connection() as connection:
|
||||||
|
row = connection.execute("SELECT status, result_json FROM invocations WHERE invocation_id = ?", (invocation_id,)).fetchone()
|
||||||
|
if row is None:
|
||||||
|
raise ValueError("Unknown invocation")
|
||||||
|
if str(row["status"]) == "finished":
|
||||||
|
# A winning concurrent command has already recorded the only
|
||||||
|
# durable result for this idempotency key. Never overwrite it.
|
||||||
|
return
|
||||||
|
cursor = connection.execute(
|
||||||
|
"UPDATE invocations SET status = 'finished', result_json = ?, updated_at = CURRENT_TIMESTAMP WHERE invocation_id = ? AND status = 'processing'",
|
||||||
|
(json.dumps(result, ensure_ascii=True), invocation_id),
|
||||||
|
)
|
||||||
|
if cursor.rowcount != 1:
|
||||||
|
raise ValueError("Invocation could not be completed")
|
||||||
|
|
||||||
|
def append_outbox(self, task_id: str, event: dict[str, Any]) -> None:
|
||||||
|
with self._lock, self._connection() as connection:
|
||||||
|
connection.execute("INSERT INTO outbox(task_id, event_json) VALUES (?, ?)", (task_id, json.dumps(event, ensure_ascii=True, sort_keys=True)))
|
||||||
|
|
||||||
|
def pending_outbox(self, limit: int = 100, *, task_id: str | None = None) -> list[dict[str, Any]]:
|
||||||
|
with self._lock, self._connection() as connection:
|
||||||
|
if task_id is None:
|
||||||
|
rows = connection.execute("SELECT event_id, task_id, event_json FROM outbox WHERE published_at IS NULL ORDER BY event_id LIMIT ?", (limit,)).fetchall()
|
||||||
|
else:
|
||||||
|
rows = connection.execute("SELECT event_id, task_id, event_json FROM outbox WHERE published_at IS NULL AND task_id = ? ORDER BY event_id LIMIT ?", (task_id, limit)).fetchall()
|
||||||
|
return [{"event_id": row["event_id"], "task_id": row["task_id"], **json.loads(row["event_json"])} for row in rows]
|
||||||
|
|
||||||
|
def mark_outbox_published(self, event_id: int) -> None:
|
||||||
|
with self._lock, self._connection() as connection:
|
||||||
|
connection.execute("UPDATE outbox SET published_at = CURRENT_TIMESTAMP WHERE event_id = ? AND published_at IS NULL", (event_id,))
|
||||||
|
|
||||||
|
def record_usage(self, task_id: str, payload: dict[str, Any]) -> None:
|
||||||
|
with self._lock, self._connection() as connection:
|
||||||
|
connection.execute("INSERT INTO usage_records(task_id, usage_json) VALUES (?, ?)", (task_id, json.dumps(payload, ensure_ascii=True, sort_keys=True)))
|
||||||
|
|
||||||
|
def record_tool_audit(self, task_id: str, payload: dict[str, Any]) -> None:
|
||||||
|
"""Persist a diagnostic structured-output audit separately from usage."""
|
||||||
|
with self._lock, self._connection() as connection:
|
||||||
|
connection.execute(
|
||||||
|
"INSERT INTO tool_audits(task_id, audit_json) VALUES (?, ?)",
|
||||||
|
(task_id, json.dumps(payload, ensure_ascii=True, sort_keys=True)),
|
||||||
|
)
|
||||||
|
|
||||||
|
def tool_audits(self, task_id: str) -> list[dict[str, Any]]:
|
||||||
|
with self._lock, self._connection() as connection:
|
||||||
|
rows = connection.execute(
|
||||||
|
"SELECT audit_id, audit_json, created_at FROM tool_audits WHERE task_id = ? ORDER BY audit_id",
|
||||||
|
(task_id,),
|
||||||
|
).fetchall()
|
||||||
|
return [
|
||||||
|
{"audit_id": int(row["audit_id"]), "at": str(row["created_at"]), **json.loads(row["audit_json"])}
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
def usage_summary(self, task_id: str) -> dict[str, Any]:
|
||||||
|
with self._lock, self._connection() as connection:
|
||||||
|
rows = connection.execute("SELECT usage_json FROM usage_records WHERE task_id = ? ORDER BY usage_id", (task_id,)).fetchall()
|
||||||
|
values = [json.loads(row["usage_json"]) for row in rows]
|
||||||
|
return {
|
||||||
|
"calls": len(values),
|
||||||
|
"prompt_tokens": sum(int(item.get("prompt_tokens") or 0) for item in values if isinstance(item, dict)),
|
||||||
|
"completion_tokens": sum(int(item.get("completion_tokens") or 0) for item in values if isinstance(item, dict)),
|
||||||
|
"context_chars": sum(int(item.get("context_chars") or 0) for item in values if isinstance(item, dict)),
|
||||||
|
"records": values,
|
||||||
|
}
|
||||||
|
|
||||||
|
def running_task_ids(self) -> list[str]:
|
||||||
|
phases = tuple(phase.value for phase in TaskPhase if phase not in {TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED, TaskPhase.WAITING_FOR_USER, TaskPhase.WAITING_RETRY})
|
||||||
|
placeholders = ", ".join("?" for _ in phases)
|
||||||
|
with self._lock, self._connection() as connection:
|
||||||
|
rows = connection.execute(f"SELECT task_id FROM tasks WHERE phase IN ({placeholders}) ORDER BY created_at", phases).fetchall()
|
||||||
|
return [str(row["task_id"]) for row in rows]
|
||||||
|
|
||||||
|
def model_capability(self, provider_id: str, model_id: str, schema_hash: str) -> dict[str, Any] | None:
|
||||||
|
with self._lock, self._connection() as connection:
|
||||||
|
row = connection.execute("SELECT supported, report_json, checked_at FROM model_capabilities WHERE provider_id = ? AND model_id = ? AND schema_hash = ?", (provider_id, model_id, schema_hash)).fetchone()
|
||||||
|
return {"supported": bool(row["supported"]), "report": json.loads(row["report_json"]), "checked_at": row["checked_at"]} if row else None
|
||||||
|
|
||||||
|
def record_model_capability(self, provider_id: str, model_id: str, schema_hash: str, report: dict[str, Any]) -> None:
|
||||||
|
with self._lock, self._connection() as connection:
|
||||||
|
connection.execute("""INSERT INTO model_capabilities(provider_id, model_id, schema_hash, supported, report_json)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(provider_id, model_id, schema_hash) DO UPDATE SET supported = excluded.supported, report_json = excluded.report_json, checked_at = CURRENT_TIMESTAMP""", (provider_id, model_id, schema_hash, int(bool(report.get("supported"))), json.dumps(report, ensure_ascii=True, sort_keys=True)))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _state(row: sqlite3.Row) -> TaskState:
|
||||||
|
raw_pending = json.loads(row["pending_action_json"]) if row["pending_action_json"] else None
|
||||||
|
pending = PendingAction(
|
||||||
|
action_id=raw_pending["action_id"], working_head=raw_pending["working_head"], intent=raw_pending["intent"],
|
||||||
|
requirement_ids=tuple(raw_pending["requirement_ids"]), atomic_id=raw_pending["atomic_id"],
|
||||||
|
expected_change=raw_pending["expected_change"], contract_hash=raw_pending["contract_hash"], idempotency_key=raw_pending["idempotency_key"],
|
||||||
|
) if isinstance(raw_pending, dict) else None
|
||||||
|
return TaskState(
|
||||||
|
task_id=str(row["task_id"]), phase=TaskPhase(str(row["phase"])), version=int(row["state_version"]),
|
||||||
|
active_revision=str(row["active_revision"] or ""), pending_action=pending,
|
||||||
|
candidate_id=str(row["candidate_id"] or ""), candidate_stage_id=str(row["candidate_stage_id"] or ""),
|
||||||
|
repair_required=bool(row["repair_required"]),
|
||||||
|
last_error=ErrorCode(str(row["last_error"])) if row["last_error"] else None,
|
||||||
|
retry_from_phase=TaskPhase(str(row["retry_from_phase"])) if row["retry_from_phase"] else None,
|
||||||
|
requirements_draft_path=str(row["requirements_draft_path"] or ""),
|
||||||
|
requirements_review_path=str(row["requirements_review_path"] or ""),
|
||||||
|
requirements_contract_path=str(row["requirements_contract_path"] or ""),
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _pending_payload(pending: PendingAction | None) -> dict[str, Any] | None:
|
||||||
|
if pending is None:
|
||||||
|
return None
|
||||||
|
return {"action_id": pending.action_id, "working_head": pending.working_head, "intent": pending.intent, "requirement_ids": list(pending.requirement_ids), "atomic_id": pending.atomic_id, "expected_change": pending.expected_change, "contract_hash": pending.contract_hash, "idempotency_key": pending.idempotency_key}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _invocation(row: sqlite3.Row) -> InvocationRecord:
|
||||||
|
return InvocationRecord(str(row["invocation_id"]), str(row["idempotency_key"]), str(row["status"]), json.loads(row["result_json"]) if row["result_json"] else None)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _lifecycle(phase: TaskPhase) -> str:
|
||||||
|
if phase == TaskPhase.COMPLETED:
|
||||||
|
return "completed"
|
||||||
|
if phase == TaskPhase.FAILED:
|
||||||
|
return "failed"
|
||||||
|
if phase == TaskPhase.CANCELLED:
|
||||||
|
return "cancelled"
|
||||||
|
if phase in {TaskPhase.WAITING_RETRY, TaskPhase.WAITING_FOR_USER}:
|
||||||
|
return phase.value.lower()
|
||||||
|
return "running"
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
"""The sole OpenAI-compatible structured-output adapter for protocol v3.
|
||||||
|
|
||||||
|
Raw tool arguments are intentionally preserved. Callers must run their
|
||||||
|
Pydantic/JSON-Schema canonical validator before causing any state transition.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from dataclasses import dataclass
|
||||||
|
import json
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from jsonschema import Draft202012Validator
|
||||||
|
|
||||||
|
from app.settings import ProviderConfig, ProviderModel, Settings
|
||||||
|
from app.cad_agent.ports import AdapterUnavailable
|
||||||
|
|
||||||
|
|
||||||
|
class StructuredModelError(AdapterUnavailable):
|
||||||
|
"""A provider-side structured-output failure, never an author mistake.
|
||||||
|
|
||||||
|
Invalid provider responses and provider-side schema rejections are outside
|
||||||
|
the model's tool arguments. The workflow must park the task for bounded
|
||||||
|
service recovery instead of spending the author-format retry budget.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class StructuredTransportError(StructuredModelError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class StructuredResponse:
|
||||||
|
tool_calls: list[dict[str, Any]]
|
||||||
|
usage: dict[str, int]
|
||||||
|
raw_response: dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
|
class StructuredModelGateway:
|
||||||
|
"""Bounded HTTP adapter; it never performs tool execution or re-asks."""
|
||||||
|
|
||||||
|
def __init__(self, settings: Settings) -> None:
|
||||||
|
self.settings = settings
|
||||||
|
|
||||||
|
async def call_tool(self, *, messages: list[dict[str, Any]], tool: dict[str, Any], provider_id: str, model_id: str, required_tool_name: str) -> dict[str, Any]:
|
||||||
|
provider, model = self._provider_model(provider_id, model_id)
|
||||||
|
payload = self._payload(provider, model.id, messages, [tool], required_tool_name)
|
||||||
|
response = await self._request(provider, payload)
|
||||||
|
try:
|
||||||
|
return self._normalized_response(provider, response)
|
||||||
|
except (KeyError, TypeError, ValueError) as error:
|
||||||
|
raise StructuredModelError("Provider response cannot be normalized as a structured tool-call result") from error
|
||||||
|
|
||||||
|
async def conformance(self, *, provider_id: str, model_id: str, tools: list[dict[str, Any]]) -> dict[str, Any]:
|
||||||
|
failures: list[dict[str, str]] = []
|
||||||
|
usage = {"prompt_tokens": 0, "completion_tokens": 0}
|
||||||
|
for tool in tools:
|
||||||
|
name = str((tool.get("function") or {}).get("name") or "")
|
||||||
|
if not name:
|
||||||
|
failures.append({"tool": "", "message": "Tool has no function name"})
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
response = await self.call_tool(
|
||||||
|
messages=self._conformance_messages(tool),
|
||||||
|
tool=tool, provider_id=provider_id, model_id=model_id, required_tool_name=name,
|
||||||
|
)
|
||||||
|
usage["prompt_tokens"] += int(response["usage"]["prompt_tokens"])
|
||||||
|
usage["completion_tokens"] += int(response["usage"]["completion_tokens"])
|
||||||
|
calls = response["tool_calls"]
|
||||||
|
if len(calls) != 1:
|
||||||
|
failures.append({"tool": name, "message": "Provider did not return exactly one tool call"})
|
||||||
|
continue
|
||||||
|
function = calls[0].get("function") if isinstance(calls[0], dict) and isinstance(calls[0].get("function"), dict) else {}
|
||||||
|
if str(function.get("name") or "") != name:
|
||||||
|
failures.append({"tool": name, "message": "Provider returned a different tool"})
|
||||||
|
continue
|
||||||
|
raw = str(function.get("arguments") or "")
|
||||||
|
if len(raw.encode("utf-8")) > 48_000:
|
||||||
|
failures.append({"tool": name, "message": "Provider arguments exceed the protocol byte limit"})
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
arguments = json.loads(raw)
|
||||||
|
except json.JSONDecodeError as error:
|
||||||
|
failures.append({"tool": name, "message": f"Provider arguments are not JSON: {error.msg}"})
|
||||||
|
continue
|
||||||
|
if not isinstance(arguments, dict):
|
||||||
|
failures.append({"tool": name, "message": "Provider arguments are not a JSON object"})
|
||||||
|
continue
|
||||||
|
schema = (tool.get("function") or {}).get("parameters")
|
||||||
|
errors = list(Draft202012Validator(schema).iter_errors(arguments)) if isinstance(schema, dict) else []
|
||||||
|
if errors:
|
||||||
|
failures.append({"tool": name, "message": f"Provider arguments violate schema: {errors[0].message[:300]}"})
|
||||||
|
except StructuredTransportError as error:
|
||||||
|
# This probe cannot establish unsupported capability while
|
||||||
|
# the provider is unavailable. Stop instead of charging for
|
||||||
|
# every remaining schema after the first transport failure.
|
||||||
|
failures.append({"tool": name, "message": str(error)[:500]})
|
||||||
|
return {
|
||||||
|
"supported": False,
|
||||||
|
"failures": failures,
|
||||||
|
"usage": usage,
|
||||||
|
"mode": "openai_compatible_tool_calling",
|
||||||
|
"probe_unavailable": True,
|
||||||
|
}
|
||||||
|
except Exception as error:
|
||||||
|
failures.append({"tool": name, "message": str(error)[:500]})
|
||||||
|
return {
|
||||||
|
"supported": not failures,
|
||||||
|
"failures": failures,
|
||||||
|
"usage": usage,
|
||||||
|
"mode": "openai_compatible_tool_calling",
|
||||||
|
"probe_unavailable": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _conformance_messages(tool: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
"""Give capability probes the same selector fact production exposes.
|
||||||
|
|
||||||
|
A required opaque selector is not inferable from a generic request for
|
||||||
|
a minimal example. Production fragment turns expose a topology token
|
||||||
|
before the fragment tool, so the probe must do the same while still
|
||||||
|
letting the provider generate every other schema field itself.
|
||||||
|
"""
|
||||||
|
function = tool.get("function") if isinstance(tool.get("function"), dict) else {}
|
||||||
|
name = str(function.get("name") or "")
|
||||||
|
parameters = function.get("parameters") if isinstance(function.get("parameters"), dict) else {}
|
||||||
|
feature = parameters.get("properties", {}).get("feature") if isinstance(parameters.get("properties"), dict) else None
|
||||||
|
feature_properties = feature.get("properties") if isinstance(feature, dict) and isinstance(feature.get("properties"), dict) else {}
|
||||||
|
feature_required = feature.get("required") if isinstance(feature, dict) and isinstance(feature.get("required"), list) else []
|
||||||
|
selector_required = "selector_tokens" in feature_required and isinstance(feature_properties.get("selector_tokens"), dict)
|
||||||
|
instruction = "Return exactly the required function call with a minimal valid example. The active JSON Schema is authoritative."
|
||||||
|
if selector_required:
|
||||||
|
atomic_id = feature_properties.get("atomic_id", {}).get("const") if isinstance(feature_properties.get("atomic_id"), dict) else "..."
|
||||||
|
instruction += (
|
||||||
|
" The current topology exposes opaque selector token `sel_conformance`. "
|
||||||
|
f"Use root shape {{\"feature\":{{\"atomic_id\":{json.dumps(atomic_id)},"
|
||||||
|
"\"selector_tokens\":[\"sel_conformance\"],\"params\":{...}}}}. "
|
||||||
|
"Because feature.selector_tokens is required, it is author input and must not be moved into params."
|
||||||
|
)
|
||||||
|
if name == "review_candidate":
|
||||||
|
instruction += (
|
||||||
|
" This is a candidate-review probe: include every required top-level and nested field; "
|
||||||
|
"in particular provide a verdict and claim_coverage. Use accept and pass only where the schema permits them."
|
||||||
|
)
|
||||||
|
elif name == "review_final":
|
||||||
|
instruction += (
|
||||||
|
" This is a final-review probe: include every required top-level and nested field; "
|
||||||
|
"in particular provide a verdict and claim_coverage. Use pass only where the schema permits it."
|
||||||
|
)
|
||||||
|
return [{"role": "system", "content": instruction}]
|
||||||
|
|
||||||
|
def _provider_model(self, provider_id: str, model_id: str) -> tuple[ProviderConfig, ProviderModel]:
|
||||||
|
return self.settings.resolve_model(provider_id, model_id)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _responses_input(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
|
values: list[dict[str, Any]] = []
|
||||||
|
for message in messages:
|
||||||
|
role = "developer" if message.get("role") == "system" else str(message.get("role") or "user")
|
||||||
|
raw_content = message.get("content")
|
||||||
|
if isinstance(raw_content, str):
|
||||||
|
content: list[dict[str, Any]] = [{"type": "input_text", "text": raw_content}]
|
||||||
|
elif isinstance(raw_content, list):
|
||||||
|
content = []
|
||||||
|
for item in raw_content:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
if item.get("type") == "text" and isinstance(item.get("text"), str):
|
||||||
|
content.append({"type": "input_text", "text": item["text"]})
|
||||||
|
elif item.get("type") == "image_url" and isinstance(item.get("image_url"), dict) and isinstance(item["image_url"].get("url"), str):
|
||||||
|
content.append({"type": "input_image", "image_url": item["image_url"]["url"]})
|
||||||
|
if not content:
|
||||||
|
content = [{"type": "input_text", "text": ""}]
|
||||||
|
else:
|
||||||
|
content = [{"type": "input_text", "text": ""}]
|
||||||
|
values.append({"role": role, "content": content})
|
||||||
|
return values
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _responses_tools(tools: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
|
values: list[dict[str, Any]] = []
|
||||||
|
for tool in tools:
|
||||||
|
function = tool.get("function") if isinstance(tool.get("function"), dict) else {}
|
||||||
|
values.append({
|
||||||
|
"type": "function", "name": str(function.get("name") or ""),
|
||||||
|
"description": str(function.get("description") or ""),
|
||||||
|
"parameters": function.get("parameters") if isinstance(function.get("parameters"), dict) else {},
|
||||||
|
})
|
||||||
|
return values
|
||||||
|
|
||||||
|
def _payload(self, provider: ProviderConfig, model_id: str, messages: list[dict[str, Any]], tools: list[dict[str, Any]], required_tool_name: str) -> dict[str, Any]:
|
||||||
|
if len(tools) != 1 or not required_tool_name:
|
||||||
|
raise StructuredModelError("The v3 protocol requires exactly one named tool per provider call.")
|
||||||
|
if provider.api_style == "responses":
|
||||||
|
payload: dict[str, Any] = {
|
||||||
|
"model": model_id, "input": self._responses_input(messages), "tools": self._responses_tools(tools),
|
||||||
|
"tool_choice": {"type": "function", "name": required_tool_name},
|
||||||
|
**provider.request_options,
|
||||||
|
}
|
||||||
|
return payload
|
||||||
|
return {
|
||||||
|
"model": model_id, "messages": messages, "tools": tools,
|
||||||
|
"tool_choice": {"type": "function", "function": {"name": required_tool_name}},
|
||||||
|
"temperature": 0, **provider.request_options,
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _normalized_response(provider: ProviderConfig, response: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
usage = response.get("usage") if isinstance(response.get("usage"), dict) else {}
|
||||||
|
if provider.api_style == "responses":
|
||||||
|
output = response.get("output") if isinstance(response.get("output"), list) else []
|
||||||
|
calls = [
|
||||||
|
{"function": {"name": str(item.get("name") or ""), "arguments": str(item.get("arguments") or "")}}
|
||||||
|
for item in output
|
||||||
|
if isinstance(item, dict) and item.get("type") == "function_call"
|
||||||
|
]
|
||||||
|
prompt_tokens = int(usage.get("input_tokens") or usage.get("prompt_tokens") or 0)
|
||||||
|
completion_tokens = int(usage.get("output_tokens") or usage.get("completion_tokens") or 0)
|
||||||
|
else:
|
||||||
|
choices = response.get("choices") if isinstance(response.get("choices"), list) else []
|
||||||
|
message = choices[0].get("message") if choices and isinstance(choices[0], dict) and isinstance(choices[0].get("message"), dict) else {}
|
||||||
|
calls = message.get("tool_calls") if isinstance(message, dict) and isinstance(message.get("tool_calls"), list) else []
|
||||||
|
prompt_tokens = int(usage.get("prompt_tokens") or 0)
|
||||||
|
completion_tokens = int(usage.get("completion_tokens") or 0)
|
||||||
|
return {
|
||||||
|
"tool_calls": calls,
|
||||||
|
"usage": {
|
||||||
|
"prompt_tokens": prompt_tokens,
|
||||||
|
"completion_tokens": completion_tokens,
|
||||||
|
"total_tokens": int(usage.get("total_tokens") or prompt_tokens + completion_tokens),
|
||||||
|
"usage_available": bool(usage),
|
||||||
|
},
|
||||||
|
"raw_response": response,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def _request(self, provider: ProviderConfig, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
headers = {"Authorization": f"Bearer {provider.api_key}", "Content-Type": "application/json"}
|
||||||
|
last_error: Exception | None = None
|
||||||
|
for attempt in range(3):
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=self.settings.llm_timeout_s) as client:
|
||||||
|
endpoint = "responses" if provider.api_style == "responses" else "chat/completions"
|
||||||
|
response = await asyncio.wait_for(client.post(f"{provider.base_url}/{endpoint}", headers=headers, json=payload), timeout=self.settings.llm_timeout_s)
|
||||||
|
if response.status_code >= 500 or response.status_code in {408, 429}:
|
||||||
|
raise StructuredTransportError(f"Provider temporary failure ({response.status_code}): {response.text[:300]}")
|
||||||
|
if response.status_code >= 400:
|
||||||
|
raise StructuredModelError(f"Provider rejected structured request ({response.status_code}): {response.text[:500]}")
|
||||||
|
try:
|
||||||
|
value = response.json()
|
||||||
|
except ValueError as error:
|
||||||
|
raise StructuredModelError("Provider response is not valid JSON") from error
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
raise StructuredModelError("Provider response is not a JSON object")
|
||||||
|
if provider.api_style == "responses":
|
||||||
|
if not isinstance(value.get("output"), list):
|
||||||
|
raise StructuredModelError("Provider response has no Responses API output array")
|
||||||
|
elif not isinstance(value.get("choices"), list) or not value["choices"]:
|
||||||
|
raise StructuredModelError("Provider response has no completion choices")
|
||||||
|
return value
|
||||||
|
except (httpx.HTTPError, asyncio.TimeoutError, StructuredTransportError) as error:
|
||||||
|
last_error = error
|
||||||
|
if attempt < 2:
|
||||||
|
await asyncio.sleep(0.5 * (2**attempt))
|
||||||
|
raise StructuredTransportError(f"Provider transport unavailable after 3 attempts: {last_error}")
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
"""Adapter exposing the pure verifier registry through the application port."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.cad_agent.domain.verifier_registry import VerifierRegistry
|
||||||
|
|
||||||
|
|
||||||
|
class RegistryVerifierExecutor:
|
||||||
|
def __init__(self, registry: VerifierRegistry) -> None:
|
||||||
|
self.registry = registry
|
||||||
|
|
||||||
|
def evaluate(self, claims: list[dict[str, Any]], facts: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
results: list[dict[str, Any]] = []
|
||||||
|
for claim in claims:
|
||||||
|
result = self.registry.evaluate(str(claim["claim_kind"]), claim["expected"], facts)
|
||||||
|
results.append({"claim_id": claim["claim_id"], "claim_kind": claim["claim_kind"], "deterministic": self.registry.definition(str(claim["claim_kind"])).deterministic, **result})
|
||||||
|
return results
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Application command handlers and context assembly for protocol v3."""
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
"""Real provider structured-output conformance gate for protocol v3."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from hashlib import sha256
|
||||||
|
import json
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.cad_agent.application.llm_contracts import (
|
||||||
|
EmptyCommand,
|
||||||
|
GeometryConclusion,
|
||||||
|
RollbackCheckpoint,
|
||||||
|
candidate_review_schema,
|
||||||
|
final_review_schema,
|
||||||
|
geometry_conclusion_schema,
|
||||||
|
next_action_schema,
|
||||||
|
operation_contract_request_schema,
|
||||||
|
requirements_draft_schema,
|
||||||
|
requirements_patch_schema,
|
||||||
|
requirements_review_schema,
|
||||||
|
rollback_checkpoint_schema,
|
||||||
|
topology_request_schema,
|
||||||
|
)
|
||||||
|
from app.cad_agent.domain.operation_contract import fragment_schema
|
||||||
|
from app.cad_agent.domain.verifier_registry import default_registry
|
||||||
|
from app.cad_agent.ports import CadRuntime, ModelGateway
|
||||||
|
|
||||||
|
|
||||||
|
_CONFORMANCE_WORKING_HEAD = "cad_conformance:root:v1"
|
||||||
|
_CONFORMANCE_REQUIREMENT_IDS = ["req_001"]
|
||||||
|
_CONFORMANCE_CLAIM_IDS = ["claim_001"]
|
||||||
|
_CONFORMANCE_DRAFT_IDS = ["draft_001"]
|
||||||
|
_CONFORMANCE_SOURCE_IDS = ["src_001"]
|
||||||
|
|
||||||
|
|
||||||
|
def conformance_tools(runtime: CadRuntime) -> list[dict[str, Any]]:
|
||||||
|
"""Return the complete v3 structured-output surface.
|
||||||
|
|
||||||
|
A provider is usable only when it can return valid arguments for every
|
||||||
|
fixed schema and every currently registered runtime operation. This list
|
||||||
|
intentionally derives the operation portion from the runtime registry so
|
||||||
|
a newly exposed operation cannot bypass the capability gate.
|
||||||
|
"""
|
||||||
|
registry = default_registry()
|
||||||
|
atomic_ids = list(runtime.supported_atomic_ids())
|
||||||
|
tools = [
|
||||||
|
_tool("submit_requirements_draft_batch", requirements_draft_schema(registry.expected_one_of_schema(), _CONFORMANCE_SOURCE_IDS)),
|
||||||
|
_tool("patch_requirements_draft", requirements_patch_schema(registry.expected_one_of_schema(), _CONFORMANCE_SOURCE_IDS, _CONFORMANCE_DRAFT_IDS)),
|
||||||
|
_tool("finalize_requirements_draft", EmptyCommand.model_json_schema()),
|
||||||
|
_tool("review_requirements", requirements_review_schema(_CONFORMANCE_SOURCE_IDS, _CONFORMANCE_DRAFT_IDS)),
|
||||||
|
_tool("propose_next_action", next_action_schema(_CONFORMANCE_WORKING_HEAD, _CONFORMANCE_REQUIREMENT_IDS, atomic_ids)),
|
||||||
|
_tool("inspect_topology", topology_request_schema(_CONFORMANCE_WORKING_HEAD)),
|
||||||
|
_tool("get_cdsl_operation_contract", operation_contract_request_schema(_CONFORMANCE_WORKING_HEAD, atomic_ids[0])),
|
||||||
|
_tool("review_candidate", candidate_review_schema("candidate_conformance", _CONFORMANCE_WORKING_HEAD, _CONFORMANCE_CLAIM_IDS)),
|
||||||
|
_tool("complete_task", EmptyCommand.model_json_schema()),
|
||||||
|
_tool("review_final", final_review_schema(_CONFORMANCE_WORKING_HEAD, _CONFORMANCE_CLAIM_IDS)),
|
||||||
|
_tool("record_geometry_conclusion", geometry_conclusion_schema(_CONFORMANCE_WORKING_HEAD, ["evidence_current_state"])),
|
||||||
|
_tool("rollback_checkpoint", rollback_checkpoint_schema(_CONFORMANCE_WORKING_HEAD, ["checkpoint_root"])),
|
||||||
|
]
|
||||||
|
if not atomic_ids:
|
||||||
|
raise RuntimeError("Runtime has no operations for conformance")
|
||||||
|
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]]) -> str:
|
||||||
|
return sha256(json.dumps(tools, ensure_ascii=True, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
async def verify_model_capability(repository: Any, runtime: CadRuntime, models: ModelGateway, *, provider_id: str, model_id: str, force: bool = False) -> dict[str, Any]:
|
||||||
|
tools = conformance_tools(runtime)
|
||||||
|
schema_hash = conformance_hash(tools)
|
||||||
|
cached = repository.model_capability(provider_id, model_id, schema_hash)
|
||||||
|
if cached is not None and cached["supported"] and not force:
|
||||||
|
return {"cached": True, "schema_hash": schema_hash, **cached["report"]}
|
||||||
|
report = await models.conformance(provider_id=provider_id, model_id=model_id, tools=tools)
|
||||||
|
report = {"schema_hash": schema_hash, "tool_count": len(tools), **report}
|
||||||
|
repository.record_model_capability(provider_id, model_id, schema_hash, report)
|
||||||
|
return report
|
||||||
|
|
||||||
|
|
||||||
|
def _tool(name: str, parameters: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": name,
|
||||||
|
"description": "Structured output conformance probe. Return one schema-valid call with every required root and nested property.",
|
||||||
|
"parameters": parameters,
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,394 @@
|
|||||||
|
"""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, 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 RequirementInput(StrictDto):
|
||||||
|
source_ids: list[Identifier] = Field(min_length=1, max_length=32)
|
||||||
|
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)
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def _source_ids_are_unique(self) -> "RequirementInput":
|
||||||
|
if len(self.source_ids) != len(set(self.source_ids)):
|
||||||
|
raise ValueError("source_ids must not contain duplicates")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class RequirementsDraftBatch(StrictDto):
|
||||||
|
items: list[RequirementInput] = Field(min_length=1, max_length=8)
|
||||||
|
|
||||||
|
|
||||||
|
class RequirementsPatch(StrictDto):
|
||||||
|
target_draft_id: Identifier = Field(description="Current server-assigned draft ID to patch. This field belongs inside one patches[] entry.")
|
||||||
|
op: Literal["replace", "remove"] = Field(description="replace supplies a complete replacement item; remove supplies a reason instead.")
|
||||||
|
item: RequirementInput | None = Field(default=None, description="Complete replacement RequirementInput for op=replace. It must not include draft_id because the server preserves that ID.")
|
||||||
|
reason: str | None = Field(default=None, min_length=1, max_length=360, description="Required only for op=remove; explains why the current draft item is removed.")
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def _complete_patch(self) -> "RequirementsPatch":
|
||||||
|
if self.op == "replace" and self.item is None:
|
||||||
|
raise ValueError("replace requires a complete item")
|
||||||
|
if self.op == "remove" and (self.item is not None or self.reason is None):
|
||||||
|
raise ValueError("remove requires a reason and forbids item")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class RequirementsPatchBatch(StrictDto):
|
||||||
|
patches: list[RequirementsPatch] = Field(min_length=1, max_length=8, description="Patch entries. Example shape: {\"patches\":[{\"target_draft_id\":\"draft_001\",\"op\":\"replace\",\"item\":{...}}]}.")
|
||||||
|
|
||||||
|
|
||||||
|
class EmptyCommand(StrictDto):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ReviewNormalization(StrictDto):
|
||||||
|
rule_id: Literal["full_circle_equal_spacing"]
|
||||||
|
count: int = Field(ge=2, le=1024)
|
||||||
|
declared_spacing_degrees: float = Field(gt=0, le=360)
|
||||||
|
full_circle: bool
|
||||||
|
|
||||||
|
|
||||||
|
class ReviewFinding(StrictDto):
|
||||||
|
draft_id: Identifier
|
||||||
|
source_ids: list[Identifier] = Field(min_length=1, max_length=32)
|
||||||
|
finding_type: Literal[
|
||||||
|
"missing_source_semantics",
|
||||||
|
"claim_mismatch",
|
||||||
|
"verification_gap",
|
||||||
|
"derivable_conflict",
|
||||||
|
"ambiguous_conflict",
|
||||||
|
]
|
||||||
|
description: Annotated[str, Field(min_length=1, max_length=1000)]
|
||||||
|
question: str | None = Field(default=None, min_length=1, max_length=360)
|
||||||
|
normalization: ReviewNormalization | None = None
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def _finding_payload_matches_type(self) -> "ReviewFinding":
|
||||||
|
if len(self.source_ids) != len(set(self.source_ids)):
|
||||||
|
raise ValueError("source_ids must not contain duplicates")
|
||||||
|
if self.finding_type == "ambiguous_conflict" and self.question is None:
|
||||||
|
raise ValueError("ambiguous_conflict requires an answerable question")
|
||||||
|
if self.finding_type == "derivable_conflict" and self.normalization is None:
|
||||||
|
raise ValueError("derivable_conflict requires structured normalization data")
|
||||||
|
if self.finding_type != "derivable_conflict" and self.normalization is not None:
|
||||||
|
raise ValueError("normalization is allowed only for derivable_conflict")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class RequirementsReview(StrictDto):
|
||||||
|
findings: list[ReviewFinding] = Field(default_factory=list, max_length=128)
|
||||||
|
|
||||||
|
|
||||||
|
class NextAction(StrictDto):
|
||||||
|
working_head: Annotated[str, Field(pattern=r"^[a-z0-9_:-]{5,192}$")]
|
||||||
|
intent: ShortText
|
||||||
|
requirement_ids: list[Identifier] = Field(min_length=1, max_length=5)
|
||||||
|
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 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 OperationContractRequest(StrictDto):
|
||||||
|
working_head: Annotated[str, Field(pattern=r"^[a-z0-9_:-]{5,192}$")]
|
||||||
|
atomic_id: Identifier
|
||||||
|
|
||||||
|
|
||||||
|
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 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 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_draft_schema(claim_one_of: dict[str, Any], source_ids: list[str]) -> dict[str, Any]:
|
||||||
|
"""Bind claim and source enums for the current requirements snapshot."""
|
||||||
|
schema = RequirementsDraftBatch.model_json_schema()
|
||||||
|
requirement = schema.get("$defs", {}).get("RequirementInput")
|
||||||
|
if isinstance(requirement, dict):
|
||||||
|
properties = requirement.get("properties", {})
|
||||||
|
source_items = properties.get("source_ids", {}).get("items") if isinstance(properties, dict) and isinstance(properties.get("source_ids"), dict) else None
|
||||||
|
if isinstance(source_items, dict):
|
||||||
|
source_items["enum"] = source_ids
|
||||||
|
claims = requirement.get("properties", {}).get("acceptance_claims")
|
||||||
|
if isinstance(claims, dict):
|
||||||
|
claims["items"] = deepcopy(claim_one_of)
|
||||||
|
return schema
|
||||||
|
|
||||||
|
|
||||||
|
def requirements_patch_schema(claim_one_of: dict[str, Any], source_ids: list[str], draft_ids: list[str]) -> dict[str, Any]:
|
||||||
|
schema = RequirementsPatchBatch.model_json_schema()
|
||||||
|
definitions = schema.get("$defs", {})
|
||||||
|
requirement = definitions.get("RequirementInput") if isinstance(definitions, dict) else None
|
||||||
|
if isinstance(requirement, dict):
|
||||||
|
properties = requirement.get("properties", {})
|
||||||
|
source_items = properties.get("source_ids", {}).get("items") if isinstance(properties, dict) and isinstance(properties.get("source_ids"), dict) else None
|
||||||
|
if isinstance(source_items, dict):
|
||||||
|
source_items["enum"] = source_ids
|
||||||
|
claims = requirement.get("properties", {}).get("acceptance_claims")
|
||||||
|
if isinstance(claims, dict):
|
||||||
|
claims["items"] = deepcopy(claim_one_of)
|
||||||
|
patch = definitions.get("RequirementsPatch") if isinstance(definitions, dict) else None
|
||||||
|
if isinstance(patch, dict):
|
||||||
|
target = patch.get("properties", {}).get("target_draft_id")
|
||||||
|
if isinstance(target, dict):
|
||||||
|
target["enum"] = draft_ids
|
||||||
|
return schema
|
||||||
|
|
||||||
|
|
||||||
|
def requirements_review_schema(source_ids: list[str], draft_ids: list[str]) -> dict[str, Any]:
|
||||||
|
schema = RequirementsReview.model_json_schema()
|
||||||
|
definitions = schema.get("$defs", {})
|
||||||
|
finding = definitions.get("ReviewFinding") if isinstance(definitions, dict) else None
|
||||||
|
if isinstance(finding, dict):
|
||||||
|
properties = finding.get("properties", {})
|
||||||
|
if isinstance(properties.get("draft_id"), dict):
|
||||||
|
properties["draft_id"] = {"enum": draft_ids}
|
||||||
|
source_items = properties.get("source_ids", {}).get("items") if isinstance(properties.get("source_ids"), dict) else None
|
||||||
|
if isinstance(source_items, dict):
|
||||||
|
source_items.clear()
|
||||||
|
source_items.update({"enum": source_ids})
|
||||||
|
return schema
|
||||||
|
|
||||||
|
|
||||||
|
def next_action_schema(working_head: str, requirement_ids: list[str], atomic_ids: list[str]) -> dict[str, Any]:
|
||||||
|
schema = NextAction.model_json_schema()
|
||||||
|
properties = schema.get("properties", {})
|
||||||
|
if isinstance(properties, dict):
|
||||||
|
properties["working_head"] = {"const": working_head}
|
||||||
|
if isinstance(properties.get("requirement_ids"), dict):
|
||||||
|
properties["requirement_ids"]["items"] = {"enum": requirement_ids}
|
||||||
|
properties["atomic_id"] = {"enum": atomic_ids}
|
||||||
|
return schema
|
||||||
|
|
||||||
|
|
||||||
|
def candidate_review_schema(candidate_id: str, working_head: str, claim_ids: list[str]) -> dict[str, Any]:
|
||||||
|
"""Bind an independent candidate review to immutable candidate facts."""
|
||||||
|
schema = CandidateReview.model_json_schema()
|
||||||
|
properties = schema.get("properties", {})
|
||||||
|
if isinstance(properties, dict):
|
||||||
|
properties["candidate_id"] = {"const": candidate_id}
|
||||||
|
properties["working_head"] = {"const": working_head}
|
||||||
|
_bind_claim_coverage_ids(schema, claim_ids)
|
||||||
|
return schema
|
||||||
|
|
||||||
|
|
||||||
|
def final_review_schema(working_head: str, claim_ids: list[str]) -> dict[str, Any]:
|
||||||
|
"""Bind final review output to the currently reviewable revision."""
|
||||||
|
schema = FinalReview.model_json_schema()
|
||||||
|
properties = schema.get("properties", {})
|
||||||
|
if isinstance(properties, dict):
|
||||||
|
properties["working_head"] = {"const": working_head}
|
||||||
|
_bind_claim_coverage_ids(schema, claim_ids)
|
||||||
|
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 operation_contract_request_schema(working_head: str, atomic_id: str) -> dict[str, Any]:
|
||||||
|
schema = OperationContractRequest.model_json_schema()
|
||||||
|
properties = schema.get("properties", {})
|
||||||
|
if isinstance(properties, dict):
|
||||||
|
properties["working_head"] = {"const": working_head}
|
||||||
|
properties["atomic_id"] = {"const": atomic_id}
|
||||||
|
return schema
|
||||||
|
|
||||||
|
|
||||||
|
def geometry_conclusion_schema(working_head: str, evidence_refs: list[str]) -> dict[str, Any]:
|
||||||
|
"""Bind a diagnostic conclusion to evidence generated for this head."""
|
||||||
|
schema = GeometryConclusion.model_json_schema()
|
||||||
|
properties = schema.get("properties", {})
|
||||||
|
if isinstance(properties, dict):
|
||||||
|
properties["working_head"] = {"const": working_head}
|
||||||
|
evidence = properties.get("evidence_refs")
|
||||||
|
if isinstance(evidence, dict):
|
||||||
|
evidence["items"] = {"enum": evidence_refs}
|
||||||
|
return schema
|
||||||
|
|
||||||
|
|
||||||
|
def rollback_checkpoint_schema(working_head: str, checkpoint_tokens: list[str]) -> dict[str, Any]:
|
||||||
|
"""Bind a rollback request to immutable checkpoints in the active lineage."""
|
||||||
|
schema = RollbackCheckpoint.model_json_schema()
|
||||||
|
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
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
"""Versioned, deterministic requirement-conflict resolutions."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
REGISTRY_VERSION = "cad.resolution-registry.v1"
|
||||||
|
FULL_CIRCLE_EQUAL_SPACING_VERSION = "full_circle_equal_spacing.v1"
|
||||||
|
|
||||||
|
|
||||||
|
class NormalizationError(ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_normalization(
|
||||||
|
*,
|
||||||
|
task_id: str,
|
||||||
|
draft_id: str,
|
||||||
|
source_ids: list[str],
|
||||||
|
description: str,
|
||||||
|
value: dict[str, Any],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
if value.get("rule_id") != "full_circle_equal_spacing":
|
||||||
|
raise NormalizationError("Unknown deterministic normalization rule.")
|
||||||
|
count = value.get("count")
|
||||||
|
declared = value.get("declared_spacing_degrees")
|
||||||
|
if not isinstance(count, int) or isinstance(count, bool) or count < 2:
|
||||||
|
raise NormalizationError("Full-circle equal spacing requires an integer count of at least 2.")
|
||||||
|
if value.get("full_circle") is not True:
|
||||||
|
raise NormalizationError("Automatic equal-spacing normalization is allowed only for a full circle.")
|
||||||
|
if not isinstance(declared, (int, float)) or isinstance(declared, bool) or not math.isfinite(float(declared)):
|
||||||
|
raise NormalizationError("Declared angular spacing must be finite.")
|
||||||
|
adopted = 360.0 / count
|
||||||
|
if math.isclose(float(declared), adopted, rel_tol=0.0, abs_tol=1e-9):
|
||||||
|
raise NormalizationError("The declared spacing already matches 360/count; there is no derivable conflict.")
|
||||||
|
return {
|
||||||
|
"schema_version": "cad.requirements-normalization.v1",
|
||||||
|
"registry_version": REGISTRY_VERSION,
|
||||||
|
"rule_id": "full_circle_equal_spacing",
|
||||||
|
"rule_version": FULL_CIRCLE_EQUAL_SPACING_VERSION,
|
||||||
|
"task_id": task_id,
|
||||||
|
"draft_id": draft_id,
|
||||||
|
"source_ids": list(source_ids),
|
||||||
|
"original": {"count": count, "spacing_degrees": float(declared), "full_circle": True},
|
||||||
|
"adopted": {"count": count, "spacing_degrees": adopted, "full_circle": True},
|
||||||
|
"reason": description,
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
"""Transactional-outbox delivery orchestration.
|
||||||
|
|
||||||
|
State transitions persist an outbox row in the same SQLite transaction. This
|
||||||
|
dispatcher is the sole path that acknowledges a row after a delivery adapter
|
||||||
|
accepts it, which gives consumers a stable event ID for idempotent handling.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.cad_agent.ports import EventPublisher, TaskRepository
|
||||||
|
|
||||||
|
|
||||||
|
class OutboxDispatcher:
|
||||||
|
def __init__(self, repository: TaskRepository, publisher: EventPublisher) -> None:
|
||||||
|
self.repository = repository
|
||||||
|
self.publisher = publisher
|
||||||
|
|
||||||
|
async def dispatch_pending(self, *, limit: int = 100, task_id: str | None = None) -> list[dict[str, Any]]:
|
||||||
|
"""Deliver pending events in order and acknowledge only on success.
|
||||||
|
|
||||||
|
A process interruption after ``publish`` but before acknowledgement is
|
||||||
|
deliberately delivered again. Consumers use the stable ``event_id``
|
||||||
|
to collapse that at-least-once replay.
|
||||||
|
"""
|
||||||
|
delivered: list[dict[str, Any]] = []
|
||||||
|
for item in self.repository.pending_outbox(limit, task_id=task_id):
|
||||||
|
event_id = item.get("event_id")
|
||||||
|
task_id = item.get("task_id")
|
||||||
|
if not isinstance(event_id, int) or not isinstance(task_id, str):
|
||||||
|
raise ValueError("Outbox row has no stable event identity")
|
||||||
|
envelope = {**item, "event_id": event_id, "task_id": task_id}
|
||||||
|
await self.publisher.publish(envelope)
|
||||||
|
self.repository.mark_outbox_published(event_id)
|
||||||
|
delivered.append(envelope)
|
||||||
|
return delivered
|
||||||
@@ -0,0 +1,665 @@
|
|||||||
|
"""Requirements drafting/review handlers; Markdown is rendered, never parsed."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from copy import deepcopy
|
||||||
|
from hashlib import sha256
|
||||||
|
import json
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.cad_agent.application.llm_contracts import (
|
||||||
|
RequirementsDraftBatch,
|
||||||
|
RequirementsPatchBatch,
|
||||||
|
RequirementsReview,
|
||||||
|
requirements_draft_schema,
|
||||||
|
requirements_patch_schema,
|
||||||
|
requirements_review_schema,
|
||||||
|
)
|
||||||
|
from app.cad_agent.application.normalization import NormalizationError, resolve_normalization
|
||||||
|
from app.cad_agent.application.results import Accepted, Rejected, Waiting
|
||||||
|
from app.cad_agent.domain.claim_matching import contains_expected
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
class RequirementsCommandHandler:
|
||||||
|
def __init__(self, repository: TaskRepository, artifacts: ArtifactStore, registry: VerifierRegistry) -> None:
|
||||||
|
self.repository = repository
|
||||||
|
self.artifacts = artifacts
|
||||||
|
self.registry = registry
|
||||||
|
# This intentionally has no production configuration path. The live
|
||||||
|
# evaluator registers its fixture oracle per task before authoring.
|
||||||
|
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:
|
||||||
|
"""Register an external contract oracle for one live-evaluation task.
|
||||||
|
|
||||||
|
The source requirement remains evidence for normal production work;
|
||||||
|
this guarded fixture data exists only to keep an evaluation from
|
||||||
|
accepting a reviewer false positive before CAD execution starts.
|
||||||
|
"""
|
||||||
|
normalized: list[dict[str, Any]] = []
|
||||||
|
for item in required_claims:
|
||||||
|
claim_kind = item.get("claim_kind") if isinstance(item, dict) else None
|
||||||
|
expected = item.get("expected") if isinstance(item, dict) else None
|
||||||
|
if (
|
||||||
|
not isinstance(claim_kind, str)
|
||||||
|
or claim_kind not in self.registry.claim_kinds
|
||||||
|
or not isinstance(expected, dict)
|
||||||
|
):
|
||||||
|
raise ValueError("Evaluation contract oracle claims require claim_kind and expected object.")
|
||||||
|
# The frozen contract is production-valid and complete. Fixture
|
||||||
|
# expectations are intentionally partial so they can compare
|
||||||
|
# provider-chosen optional fields such as tolerances.
|
||||||
|
normalized.append({"claim_kind": claim_kind, "expected": deepcopy(expected)})
|
||||||
|
if not normalized:
|
||||||
|
raise ValueError("Evaluation contract oracle requires at least one claim.")
|
||||||
|
self._evaluation_contract_oracles[task_id] = normalized
|
||||||
|
gaps: list[dict[str, str]] = []
|
||||||
|
for gap in validation_capability_gaps or ():
|
||||||
|
gap_id = gap.get("id") if isinstance(gap, dict) else None
|
||||||
|
description = gap.get("description") if isinstance(gap, dict) else None
|
||||||
|
if not isinstance(gap_id, str) or not gap_id or not isinstance(description, str) or not description:
|
||||||
|
raise ValueError("Evaluation capability gaps require id and description.")
|
||||||
|
gaps.append({"id": gap_id, "description": description})
|
||||||
|
self._evaluation_capability_gaps[task_id] = gaps
|
||||||
|
|
||||||
|
def evaluation_review_context(self, task_id: str) -> dict[str, Any] | None:
|
||||||
|
"""Expose fixture-only known verifier gaps to the live reviewer."""
|
||||||
|
gaps = self._evaluation_capability_gaps.get(task_id)
|
||||||
|
if gaps is None:
|
||||||
|
return None
|
||||||
|
return {"evaluation_only": True, "known_validation_capability_gaps": deepcopy(gaps)}
|
||||||
|
|
||||||
|
def draft_schema(self, task_id: str) -> dict[str, Any]:
|
||||||
|
return requirements_draft_schema(self.registry.expected_one_of_schema(), list(self.artifacts.read_source_index(task_id)))
|
||||||
|
|
||||||
|
def patch_schema(self, task_id: str) -> dict[str, Any]:
|
||||||
|
state = self.repository.get_state(task_id)
|
||||||
|
draft = self._draft(state)
|
||||||
|
ids = [str(item.get("draft_id") or "") for item in draft.get("items") or () if isinstance(item, dict) and item.get("draft_id")]
|
||||||
|
return requirements_patch_schema(self.registry.expected_one_of_schema(), list(self.artifacts.read_source_index(task_id)), ids)
|
||||||
|
|
||||||
|
def review_schema(self, task_id: str) -> dict[str, Any]:
|
||||||
|
state = self.repository.get_state(task_id)
|
||||||
|
draft = self._draft(state)
|
||||||
|
draft_ids = [str(item.get("draft_id") or "") for item in draft.get("items") or () if isinstance(item, dict) and item.get("draft_id")]
|
||||||
|
source_ids = list(self.artifacts.read_source_index(task_id))
|
||||||
|
return requirements_review_schema(source_ids, draft_ids)
|
||||||
|
|
||||||
|
def submit_draft(self, task_id: str, batch: RequirementsDraftBatch, *, 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:
|
||||||
|
return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "Task does not exist."))
|
||||||
|
if state.phase != TaskPhase.DRAFTING_REQUIREMENTS:
|
||||||
|
return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "Requirements cannot be drafted in the current workflow phase."))
|
||||||
|
draft = self._draft(state)
|
||||||
|
source_index = self.artifacts.read_source_index(task_id)
|
||||||
|
current = [deepcopy(item) for item in draft.get("items") or () if isinstance(item, dict)]
|
||||||
|
errors = self._validate_items(batch.items, source_index, current)
|
||||||
|
if errors:
|
||||||
|
return Rejected(WorkflowError(ErrorCode.AUTHOR_FORMAT_INVALID, "Requirements draft batch failed canonical validation.", field_errors=tuple(errors)))
|
||||||
|
invocation = self.repository.begin_invocation(
|
||||||
|
task_id,
|
||||||
|
invocation_id,
|
||||||
|
self._key(task_id, "requirements_draft", state.working_head, batch.model_dump(mode="json")),
|
||||||
|
)
|
||||||
|
if invocation.status == "finished" and invocation.result is not None:
|
||||||
|
return self._restore(invocation.result)
|
||||||
|
start = len(current) + 1
|
||||||
|
for position, item in enumerate(batch.items, start):
|
||||||
|
current.append({"draft_id": f"draft_{position:03d}", **item.model_dump(mode="json")})
|
||||||
|
payload = {"schema_version": "cad.requirements-draft.v1", "revision": int(draft.get("revision") or 0) + 1, "items": current}
|
||||||
|
try:
|
||||||
|
artifact_path = self.artifacts.write_requirements_draft(task_id, payload, invocation_id=invocation_id)
|
||||||
|
except OSError as error:
|
||||||
|
return self._park_for_storage_retry(
|
||||||
|
state,
|
||||||
|
event="requirements_draft_storage_failure",
|
||||||
|
message=str(error),
|
||||||
|
)
|
||||||
|
next_state = transition(state, "draft_updated", requirements_draft_path=artifact_path)
|
||||||
|
result = Accepted({"draft_revision": payload["revision"], "draft_ids": [item["draft_id"] for item in current[-len(batch.items):]]})
|
||||||
|
if not self._commit(next_state, [{"event": "requirements_draft_updated", "invocation_id": invocation_id, "draft_revision": payload["revision"], "draft_path": artifact_path, "item_count": len(current)}], invocation, result):
|
||||||
|
return Rejected(self._stale())
|
||||||
|
return result
|
||||||
|
|
||||||
|
def patch_draft(self, task_id: str, batch: RequirementsPatchBatch, *, invocation_id: str) -> Accepted | Rejected:
|
||||||
|
replay = self._replay(task_id, invocation_id)
|
||||||
|
if replay is not None:
|
||||||
|
return replay
|
||||||
|
state = self.repository.get_state(task_id)
|
||||||
|
if state is None or state.phase != TaskPhase.DRAFTING_REQUIREMENTS:
|
||||||
|
return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "Requirements patches are not allowed in the current workflow phase."))
|
||||||
|
draft = self._draft(state)
|
||||||
|
items = [deepcopy(item) for item in draft.get("items") or () if isinstance(item, dict)]
|
||||||
|
by_id = {str(item.get("draft_id") or ""): item for item in items}
|
||||||
|
source_index = self.artifacts.read_source_index(task_id)
|
||||||
|
errors: list[dict[str, str]] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for index, patch in enumerate(batch.patches):
|
||||||
|
if patch.target_draft_id in seen:
|
||||||
|
errors.append({"path": f"/patches/{index}/target_draft_id", "message": "A draft item may be patched only once per batch."})
|
||||||
|
seen.add(patch.target_draft_id)
|
||||||
|
if patch.target_draft_id not in by_id:
|
||||||
|
errors.append({"path": f"/patches/{index}/target_draft_id", "message": "Unknown current draft ID."})
|
||||||
|
if patch.item:
|
||||||
|
errors.extend(self._validate_items([patch.item], source_index, [item for item in items if item.get("draft_id") != patch.target_draft_id], prefix=f"/patches/{index}/item"))
|
||||||
|
if errors:
|
||||||
|
return Rejected(WorkflowError(ErrorCode.AUTHOR_FORMAT_INVALID, "Requirements patch batch failed canonical validation.", field_errors=tuple(errors)))
|
||||||
|
invocation = self.repository.begin_invocation(
|
||||||
|
task_id,
|
||||||
|
invocation_id,
|
||||||
|
self._key(task_id, "requirements_patch", state.working_head, batch.model_dump(mode="json")),
|
||||||
|
)
|
||||||
|
if invocation.status == "finished" and invocation.result is not None:
|
||||||
|
return self._restore(invocation.result)
|
||||||
|
remaining: list[dict[str, Any]] = []
|
||||||
|
patches = {patch.target_draft_id: patch for patch in batch.patches}
|
||||||
|
for item in items:
|
||||||
|
patch = patches.get(str(item["draft_id"]))
|
||||||
|
if patch is None:
|
||||||
|
remaining.append(item)
|
||||||
|
elif patch.op == "replace" and patch.item is not None:
|
||||||
|
remaining.append({"draft_id": item["draft_id"], **patch.item.model_dump(mode="json")})
|
||||||
|
payload = {"schema_version": "cad.requirements-draft.v1", "revision": int(draft.get("revision") or 0) + 1, "items": remaining}
|
||||||
|
try:
|
||||||
|
artifact_path = self.artifacts.write_requirements_draft(task_id, payload, invocation_id=invocation_id)
|
||||||
|
except OSError as error:
|
||||||
|
return self._park_for_storage_retry(
|
||||||
|
state,
|
||||||
|
event="requirements_patch_storage_failure",
|
||||||
|
message=str(error),
|
||||||
|
)
|
||||||
|
# A successful patch creates a new draft revision. The prior review
|
||||||
|
# describes the old revision and must not keep the author trapped in
|
||||||
|
# patch-only mode; it will be replaced after explicit re-finalization.
|
||||||
|
next_state = transition(
|
||||||
|
state,
|
||||||
|
"draft_updated",
|
||||||
|
requirements_draft_path=artifact_path,
|
||||||
|
requirements_review_path="",
|
||||||
|
)
|
||||||
|
result = Accepted({"draft_revision": payload["revision"], "item_count": len(remaining)})
|
||||||
|
if not self._commit(next_state, [{"event": "requirements_draft_patched", "invocation_id": invocation_id, "draft_revision": payload["revision"], "draft_path": artifact_path, "item_count": len(remaining)}], invocation, result):
|
||||||
|
return Rejected(self._stale())
|
||||||
|
return result
|
||||||
|
|
||||||
|
def finalize_draft(self, task_id: str, *, invocation_id: str) -> Accepted | Rejected:
|
||||||
|
replay = self._replay(task_id, invocation_id)
|
||||||
|
if replay is not None:
|
||||||
|
return replay
|
||||||
|
state = self.repository.get_state(task_id)
|
||||||
|
if state is None or state.phase != TaskPhase.DRAFTING_REQUIREMENTS:
|
||||||
|
return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "Requirements cannot be finalized in the current workflow phase."))
|
||||||
|
draft = self._draft(state)
|
||||||
|
items = [item for item in draft.get("items") or () if isinstance(item, dict)]
|
||||||
|
source_index = self.artifacts.read_source_index(task_id)
|
||||||
|
covered = {str(source) for item in items for source in item.get("source_ids") or ()}
|
||||||
|
missing = sorted(set(source_index) - covered)
|
||||||
|
if not items or missing:
|
||||||
|
return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "REQUIREMENTS_COVERAGE_INCOMPLETE", details={"missing_source_ids": missing}))
|
||||||
|
invocation = self.repository.begin_invocation(task_id, invocation_id, self._key(task_id, "requirements_finalize", state.working_head, {}))
|
||||||
|
if invocation.status == "finished" and invocation.result is not None:
|
||||||
|
return self._restore(invocation.result)
|
||||||
|
next_state = transition(state, "requirements_finalized")
|
||||||
|
result = Accepted({"phase": next_state.phase.value, "draft_revision": draft.get("revision", 0)})
|
||||||
|
if not self._commit(next_state, [{"event": "requirements_review_requested", "invocation_id": invocation_id, "draft_revision": draft.get("revision", 0)}], invocation, result):
|
||||||
|
return Rejected(self._stale())
|
||||||
|
return result
|
||||||
|
|
||||||
|
def record_review(self, task_id: str, review: RequirementsReview, *, invocation_id: str) -> Accepted | Rejected | Waiting:
|
||||||
|
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.REVIEWING_REQUIREMENTS:
|
||||||
|
return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "Requirements review is not expected in the current workflow phase."))
|
||||||
|
draft = self._draft(state)
|
||||||
|
items = [item for item in draft.get("items") or () if isinstance(item, dict)]
|
||||||
|
source_ids = set(self.artifacts.read_source_index(task_id))
|
||||||
|
draft_ids = {str(item.get("draft_id") or "") for item in items}
|
||||||
|
errors = self._validate_review(review, source_ids, items, draft_ids)
|
||||||
|
if errors:
|
||||||
|
return Rejected(WorkflowError(ErrorCode.AUTHOR_FORMAT_INVALID, "Requirements review failed canonical validation.", field_errors=tuple(errors)))
|
||||||
|
invocation = self.repository.begin_invocation(
|
||||||
|
task_id,
|
||||||
|
invocation_id,
|
||||||
|
self._key(task_id, "requirements_review", state.working_head, review.model_dump(mode="json")),
|
||||||
|
)
|
||||||
|
if invocation.status == "finished" and invocation.result is not None:
|
||||||
|
return self._restore(invocation.result)
|
||||||
|
coverage = self._derive_coverage(source_ids, items)
|
||||||
|
normalizations: list[dict[str, Any]] = []
|
||||||
|
normalization_errors: list[dict[str, str]] = []
|
||||||
|
for index, finding in enumerate(review.findings):
|
||||||
|
if finding.finding_type != "derivable_conflict" or finding.normalization is None:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
normalizations.append(resolve_normalization(
|
||||||
|
task_id=task_id,
|
||||||
|
draft_id=finding.draft_id,
|
||||||
|
source_ids=list(finding.source_ids),
|
||||||
|
description=finding.description,
|
||||||
|
value=finding.normalization.model_dump(mode="json"),
|
||||||
|
))
|
||||||
|
except NormalizationError as error:
|
||||||
|
normalization_errors.append({"path": f"/findings/{index}/normalization", "message": str(error)})
|
||||||
|
if normalization_errors:
|
||||||
|
return Rejected(WorkflowError(
|
||||||
|
ErrorCode.AUTHOR_FORMAT_INVALID,
|
||||||
|
"Requirements review supplied an invalid deterministic normalization.",
|
||||||
|
field_errors=tuple(normalization_errors),
|
||||||
|
))
|
||||||
|
warnings = [
|
||||||
|
finding.description
|
||||||
|
for finding in review.findings
|
||||||
|
if finding.finding_type == "verification_gap"
|
||||||
|
]
|
||||||
|
author_findings = [
|
||||||
|
finding for finding in review.findings
|
||||||
|
if finding.finding_type in {"missing_source_semantics", "claim_mismatch"}
|
||||||
|
or (
|
||||||
|
finding.finding_type == "verification_gap"
|
||||||
|
and not self._draft_has_visual_claim(items, finding.draft_id)
|
||||||
|
)
|
||||||
|
]
|
||||||
|
ambiguous = [finding for finding in review.findings if finding.finding_type == "ambiguous_conflict"]
|
||||||
|
decision = "waiting_for_user" if ambiguous else "revise" if author_findings else "freeze"
|
||||||
|
review_payload = {
|
||||||
|
"schema_version": "cad.requirements-review.v2",
|
||||||
|
"findings": [finding.model_dump(mode="json") for finding in review.findings],
|
||||||
|
"coverage": coverage,
|
||||||
|
"decision": decision,
|
||||||
|
"verification_warnings": warnings,
|
||||||
|
"applied_normalizations": normalizations,
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
review_path = self.artifacts.write_requirements_review(task_id, review_payload, invocation_id=invocation_id)
|
||||||
|
except OSError as error:
|
||||||
|
return self._park_for_storage_retry(
|
||||||
|
state,
|
||||||
|
event="requirements_review_storage_failure",
|
||||||
|
message=str(error),
|
||||||
|
)
|
||||||
|
if ambiguous:
|
||||||
|
next_state = transition(state, "waiting_for_user", error=ErrorCode.WAITING_FOR_USER, requirements_review_path=review_path)
|
||||||
|
questions = [str(finding.question) for finding in ambiguous if finding.question]
|
||||||
|
message = "Requirements contain an ambiguity that cannot be resolved deterministically."
|
||||||
|
ambiguous_findings = [finding.model_dump(mode="json") for finding in ambiguous]
|
||||||
|
result = Waiting(WorkflowError(
|
||||||
|
ErrorCode.WAITING_FOR_USER,
|
||||||
|
message,
|
||||||
|
details={"questions": questions, "findings": ambiguous_findings},
|
||||||
|
))
|
||||||
|
if not self._commit(next_state, [{
|
||||||
|
"event": "requirements_waiting_for_user",
|
||||||
|
"invocation_id": invocation_id,
|
||||||
|
"review_path": review_path,
|
||||||
|
"message": message,
|
||||||
|
"questions": questions,
|
||||||
|
"findings": ambiguous_findings,
|
||||||
|
}], invocation, result):
|
||||||
|
return Rejected(self._stale())
|
||||||
|
return result
|
||||||
|
if author_findings:
|
||||||
|
next_state = transition(state, "requirements_revise", requirements_review_path=review_path)
|
||||||
|
result = Accepted({"phase": next_state.phase.value, "review": review_payload})
|
||||||
|
if not self._commit(next_state, [{"event": "requirements_review_revise", "invocation_id": invocation_id, "review_path": review_path}], invocation, result):
|
||||||
|
return Rejected(self._stale())
|
||||||
|
return result
|
||||||
|
if normalizations:
|
||||||
|
normalization_digest = sha256(
|
||||||
|
json.dumps(normalizations, ensure_ascii=True, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
||||||
|
).hexdigest()[:12]
|
||||||
|
try:
|
||||||
|
normalization_path = self.artifacts.write_json_once(
|
||||||
|
task_id,
|
||||||
|
f"documents/requirements-normalizations-{normalization_digest}.json",
|
||||||
|
{
|
||||||
|
"schema_version": "cad.requirements-normalizations.v1",
|
||||||
|
"task_id": task_id,
|
||||||
|
"items": normalizations,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except OSError as error:
|
||||||
|
return self._park_for_storage_retry(
|
||||||
|
state,
|
||||||
|
event="requirements_normalization_storage_failure",
|
||||||
|
message=str(error),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
normalization_path = ""
|
||||||
|
contract = self._freeze_contract(
|
||||||
|
task_id,
|
||||||
|
items,
|
||||||
|
verification_warnings=warnings,
|
||||||
|
applied_normalizations=normalizations,
|
||||||
|
)
|
||||||
|
missing_claims = self._missing_evaluation_oracle_claims(task_id, contract)
|
||||||
|
if missing_claims:
|
||||||
|
oracle_payload = self._evaluation_oracle_failure(
|
||||||
|
task_id,
|
||||||
|
draft_revision=int(draft.get("revision") or 0),
|
||||||
|
reviewer_review_path=review_path,
|
||||||
|
draft_ids=sorted(draft_ids),
|
||||||
|
missing_claims=missing_claims,
|
||||||
|
)
|
||||||
|
oracle_digest = sha256(
|
||||||
|
json.dumps(oracle_payload, ensure_ascii=True, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
||||||
|
).hexdigest()[:12]
|
||||||
|
try:
|
||||||
|
oracle_path = self.artifacts.write_json_once(
|
||||||
|
task_id,
|
||||||
|
f"documents/evaluation-contract-oracle-{oracle_digest}.json",
|
||||||
|
oracle_payload,
|
||||||
|
)
|
||||||
|
except OSError as error:
|
||||||
|
return self._park_for_storage_retry(
|
||||||
|
state,
|
||||||
|
event="evaluation_contract_oracle_storage_failure",
|
||||||
|
message=str(error),
|
||||||
|
)
|
||||||
|
next_state = transition(state, "requirements_revise", requirements_review_path=oracle_path)
|
||||||
|
result = Accepted({
|
||||||
|
"phase": next_state.phase.value,
|
||||||
|
"review": oracle_payload,
|
||||||
|
"reviewer_review_path": review_path,
|
||||||
|
"evaluation_oracle": {"missing_claims": missing_claims},
|
||||||
|
})
|
||||||
|
if not self._commit(next_state, [{
|
||||||
|
"event": "requirements_evaluation_oracle_revise",
|
||||||
|
"invocation_id": invocation_id,
|
||||||
|
"review_path": review_path,
|
||||||
|
"oracle_path": oracle_path,
|
||||||
|
"missing_claims": missing_claims,
|
||||||
|
}], invocation, result):
|
||||||
|
return Rejected(self._stale())
|
||||||
|
return result
|
||||||
|
try:
|
||||||
|
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,
|
||||||
|
event="requirements_contract_storage_failure",
|
||||||
|
message=str(error),
|
||||||
|
)
|
||||||
|
next_state = transition(
|
||||||
|
state,
|
||||||
|
"requirements_approved",
|
||||||
|
requirements_review_path=review_path,
|
||||||
|
requirements_contract_path=contract_path,
|
||||||
|
)
|
||||||
|
result = Accepted({
|
||||||
|
"phase": next_state.phase.value,
|
||||||
|
"contract_hash": contract["contract_hash"],
|
||||||
|
"verification_warnings": warnings,
|
||||||
|
"applied_normalizations": normalizations,
|
||||||
|
})
|
||||||
|
events = []
|
||||||
|
if normalizations:
|
||||||
|
events.append({
|
||||||
|
"event": "requirements_normalized",
|
||||||
|
"normalization_path": normalization_path,
|
||||||
|
"applied_normalizations": normalizations,
|
||||||
|
})
|
||||||
|
events.append({"event": "requirements_contract_frozen", "invocation_id": invocation_id, "contract_hash": contract["contract_hash"], "contract_path": contract_path, "review_path": review_path, "requirement_count": len(contract["requirements"]), "verification_warnings": warnings, "applied_normalizations": normalizations})
|
||||||
|
if not self._commit(next_state, events, invocation, result):
|
||||||
|
return Rejected(self._stale())
|
||||||
|
return result
|
||||||
|
|
||||||
|
def ensure_rendered_contract_views(self, task_id: str, state: TaskState) -> None:
|
||||||
|
"""Rebuild read-only contract views from the committed immutable path.
|
||||||
|
|
||||||
|
The SQLite transition is authoritative. Rendering convenience files
|
||||||
|
after its commit must never reclassify a frozen contract as an
|
||||||
|
internal authoring failure, so this idempotent operation is replayed
|
||||||
|
by the workflow until every view exists.
|
||||||
|
"""
|
||||||
|
if not state.requirements_contract_path:
|
||||||
|
return
|
||||||
|
contract = self.artifacts.read_requirements_contract(task_id, state.requirements_contract_path)
|
||||||
|
if not isinstance(contract, dict):
|
||||||
|
raise RuntimeError("Committed requirements contract is unavailable")
|
||||||
|
self.artifacts.write_requirements_contract(task_id, contract)
|
||||||
|
self.artifacts.write_json_once(task_id, "requirements-index.json", {
|
||||||
|
"schema_version": "cad.requirements-index.v1",
|
||||||
|
"requirements": [
|
||||||
|
{
|
||||||
|
"requirement_id": item["requirement_id"],
|
||||||
|
"claim_ids": [claim["claim_id"] for claim in item["acceptance_claims"]],
|
||||||
|
}
|
||||||
|
for item in contract.get("requirements") or ()
|
||||||
|
if isinstance(item, dict)
|
||||||
|
],
|
||||||
|
})
|
||||||
|
self.artifacts.write_text_once(task_id, "requirements.md", self._requirements_markdown(contract))
|
||||||
|
self.artifacts.write_text_once(task_id, "completion.md", self._completion_markdown(contract))
|
||||||
|
|
||||||
|
def _replay(self, task_id: str, invocation_id: str) -> Accepted | Waiting | None:
|
||||||
|
invocation = self.repository.get_invocation(task_id, invocation_id)
|
||||||
|
if invocation is None or invocation.status != "finished" or invocation.result is None:
|
||||||
|
return None
|
||||||
|
return self._restore(invocation.result)
|
||||||
|
|
||||||
|
def _finish(self, invocation_id: str, result: Accepted | Waiting) -> None:
|
||||||
|
if isinstance(result, Waiting):
|
||||||
|
payload = {"result_type": "waiting", "error": result.error.payload()}
|
||||||
|
else:
|
||||||
|
payload = {"result_type": "accepted", "payload": result.payload}
|
||||||
|
self.repository.finish_invocation(invocation_id, payload)
|
||||||
|
|
||||||
|
def _commit(self, state: TaskState, events: list[dict[str, Any]], invocation: Any, result: Accepted | Waiting) -> bool:
|
||||||
|
if isinstance(result, Waiting):
|
||||||
|
payload = {"result_type": "waiting", "error": result.error.payload()}
|
||||||
|
else:
|
||||||
|
payload = {"result_type": "accepted", "payload": result.payload}
|
||||||
|
return self.repository.compare_and_swap(
|
||||||
|
state,
|
||||||
|
events=events,
|
||||||
|
invocation_id=invocation.invocation_id,
|
||||||
|
invocation_result=payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _restore(payload: dict[str, Any]) -> Accepted | Waiting:
|
||||||
|
if payload.get("result_type") == "waiting":
|
||||||
|
error = payload.get("error") if isinstance(payload.get("error"), dict) else {}
|
||||||
|
return Waiting(WorkflowError(
|
||||||
|
ErrorCode(str(error.get("code") or ErrorCode.WAITING_FOR_USER.value)),
|
||||||
|
str(error.get("message") or "Requirements need a user decision."),
|
||||||
|
tuple(error.get("field_errors") or ()),
|
||||||
|
bool(error.get("retryable")),
|
||||||
|
dict(error.get("details") or {}),
|
||||||
|
))
|
||||||
|
value = payload.get("payload") if isinstance(payload.get("payload"), dict) else payload
|
||||||
|
return Accepted(value)
|
||||||
|
|
||||||
|
def _draft(self, state: TaskState | None) -> dict[str, Any]:
|
||||||
|
return self.artifacts.read_requirements_draft(state.task_id, state.requirements_draft_path) if state is not None else {"schema_version": "cad.requirements-draft.v1", "revision": 0, "items": []}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _key(task_id: str, kind: str, head: str, value: dict[str, Any]) -> str:
|
||||||
|
encoded = json.dumps(value, ensure_ascii=True, sort_keys=True, separators=(",", ":"))
|
||||||
|
return sha256(f"{task_id}|{kind}|{head}|{encoded}".encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
def _validate_items(self, inputs: list[Any], source_index: dict[str, str], existing: list[dict[str, Any]], *, prefix: str = "/items") -> list[dict[str, str]]:
|
||||||
|
errors: list[dict[str, str]] = []
|
||||||
|
seen = {(tuple(item.get("source_ids") or ()), str(item.get("statement") or "").casefold()) for item in existing}
|
||||||
|
for index, item in enumerate(inputs):
|
||||||
|
path = f"{prefix}/{index}"
|
||||||
|
if not set(item.source_ids).issubset(source_index):
|
||||||
|
errors.append({"path": f"{path}/source_ids", "message": "source_ids must come from the current source index."})
|
||||||
|
signature = (tuple(item.source_ids), item.statement.casefold())
|
||||||
|
if signature in seen:
|
||||||
|
errors.append({"path": f"{path}/statement", "message": "Duplicate requirement item."})
|
||||||
|
seen.add(signature)
|
||||||
|
for claim_index, claim in enumerate(item.acceptance_claims):
|
||||||
|
try:
|
||||||
|
claim_errors = self.registry.validate_expected(claim.claim_kind, claim.expected)
|
||||||
|
except ValueError:
|
||||||
|
errors.append({"path": f"{path}/acceptance_claims/{claim_index}/claim_kind", "message": "VERIFIER_UNAVAILABLE"})
|
||||||
|
continue
|
||||||
|
errors.extend({"path": f"{path}/acceptance_claims/{claim_index}/expected{error['path']}", "message": error["message"]} for error in claim_errors)
|
||||||
|
return errors
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _validate_review(
|
||||||
|
review: RequirementsReview,
|
||||||
|
source_ids: set[str],
|
||||||
|
items: list[dict[str, Any]],
|
||||||
|
draft_ids: set[str],
|
||||||
|
) -> list[dict[str, str]]:
|
||||||
|
errors: list[dict[str, str]] = []
|
||||||
|
by_draft = {str(item.get("draft_id") or ""): item for item in items}
|
||||||
|
for index, finding in enumerate(review.findings):
|
||||||
|
if finding.draft_id not in draft_ids:
|
||||||
|
errors.append({"path": f"/findings/{index}/draft_id", "message": "Finding must reference a current draft item."})
|
||||||
|
continue
|
||||||
|
if not set(finding.source_ids).issubset(source_ids):
|
||||||
|
errors.append({"path": f"/findings/{index}/source_ids", "message": "Finding source_ids must come from the current source index."})
|
||||||
|
continue
|
||||||
|
cited = set(by_draft[finding.draft_id].get("source_ids") or ())
|
||||||
|
if not set(finding.source_ids).issubset(cited):
|
||||||
|
errors.append({"path": f"/findings/{index}/source_ids", "message": "Finding source_ids must be cited by its draft item."})
|
||||||
|
return errors
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _derive_coverage(source_ids: set[str], items: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
|
coverage = {source_id: [] for source_id in sorted(source_ids)}
|
||||||
|
for position, item in enumerate(items, 1):
|
||||||
|
requirement_id = f"req_{position:03d}"
|
||||||
|
for source_id in item.get("source_ids") or ():
|
||||||
|
if source_id in coverage:
|
||||||
|
coverage[source_id].append(requirement_id)
|
||||||
|
return [{"source_id": source_id, "requirement_ids": requirement_ids} for source_id, requirement_ids in coverage.items()]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _draft_has_visual_claim(items: list[dict[str, Any]], draft_id: str) -> bool:
|
||||||
|
return any(
|
||||||
|
claim.get("claim_kind") == "visual"
|
||||||
|
for item in items
|
||||||
|
if item.get("draft_id") == draft_id
|
||||||
|
for claim in item.get("acceptance_claims") or ()
|
||||||
|
if isinstance(claim, dict)
|
||||||
|
)
|
||||||
|
|
||||||
|
def _freeze_contract(
|
||||||
|
self,
|
||||||
|
task_id: str,
|
||||||
|
items: list[dict[str, Any]],
|
||||||
|
*,
|
||||||
|
verification_warnings: list[str],
|
||||||
|
applied_normalizations: list[dict[str, Any]],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
requirements: list[dict[str, Any]] = []
|
||||||
|
claim_position = 1
|
||||||
|
for position, item in enumerate(items, 1):
|
||||||
|
claims: list[dict[str, Any]] = []
|
||||||
|
for claim in item.get("acceptance_claims") or ():
|
||||||
|
deterministic = self.registry.definition(str(claim["claim_kind"])).deterministic
|
||||||
|
claims.append({"claim_id": f"claim_{claim_position:03d}", "claim_kind": claim["claim_kind"], "expected": claim["expected"], "verification_mode": "deterministic" if deterministic else "visual"})
|
||||||
|
claim_position += 1
|
||||||
|
requirements.append({"requirement_id": f"req_{position:03d}", "draft_id": item["draft_id"], "source_ids": item["source_ids"], "statement": item["statement"], "assumptions": item["assumptions"], "acceptance_claims": claims})
|
||||||
|
payload = {
|
||||||
|
"schema_version": "cad.requirements-contract.v2",
|
||||||
|
"task_id": task_id,
|
||||||
|
"requirements": requirements,
|
||||||
|
"verification_warnings": list(verification_warnings),
|
||||||
|
"applied_normalizations": deepcopy(applied_normalizations),
|
||||||
|
}
|
||||||
|
payload["contract_hash"] = sha256(json.dumps(payload, ensure_ascii=True, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest()
|
||||||
|
return payload
|
||||||
|
|
||||||
|
def _missing_evaluation_oracle_claims(self, task_id: str, contract: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
required = self._evaluation_contract_oracles.get(task_id)
|
||||||
|
if not required:
|
||||||
|
return []
|
||||||
|
actual = [
|
||||||
|
{"claim_kind": str(claim.get("claim_kind") or ""), "expected": claim.get("expected")}
|
||||||
|
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("expected"), dict)
|
||||||
|
]
|
||||||
|
return [
|
||||||
|
deepcopy(claim)
|
||||||
|
for claim in required
|
||||||
|
if not any(
|
||||||
|
actual_claim["claim_kind"] == claim["claim_kind"]
|
||||||
|
and contains_expected(actual_claim["expected"], claim["expected"])
|
||||||
|
for actual_claim in actual
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _evaluation_oracle_failure(
|
||||||
|
task_id: str,
|
||||||
|
*,
|
||||||
|
draft_revision: int,
|
||||||
|
reviewer_review_path: str,
|
||||||
|
draft_ids: list[str],
|
||||||
|
missing_claims: list[dict[str, Any]],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"schema_version": "cad.requirements-evaluation-oracle.v1",
|
||||||
|
"task_id": task_id,
|
||||||
|
"evaluation_only": True,
|
||||||
|
"decision": "revise",
|
||||||
|
"draft_revision": draft_revision,
|
||||||
|
"reviewer_decision": "freeze",
|
||||||
|
"reviewer_review_path": reviewer_review_path,
|
||||||
|
"findings": [
|
||||||
|
{
|
||||||
|
"draft_id": draft_id,
|
||||||
|
"source_ids": [],
|
||||||
|
"finding_type": "missing_source_semantics",
|
||||||
|
"description": "The external live-evaluation contract oracle found missing executable acceptance claims.",
|
||||||
|
}
|
||||||
|
for draft_id in draft_ids
|
||||||
|
],
|
||||||
|
"missing_claims": missing_claims,
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _requirements_markdown(contract: dict[str, Any]) -> str:
|
||||||
|
rows = ["# Requirements", ""]
|
||||||
|
for item in contract["requirements"]:
|
||||||
|
rows.append(f"- {item['requirement_id']}: {item['statement']}")
|
||||||
|
for assumption in item["assumptions"]:
|
||||||
|
rows.append(f" - Assumption: {assumption}")
|
||||||
|
return "\n".join(rows) + "\n"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _completion_markdown(contract: dict[str, Any]) -> str:
|
||||||
|
return "# Completion\n\n" + "\n".join(f"- [ ] {item['requirement_id']}: {item['statement']}" for item in contract["requirements"]) + "\n"
|
||||||
|
|
||||||
|
@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, *, event: str, message: str) -> Rejected:
|
||||||
|
waiting = transition(state, "waiting_retry", error=ErrorCode.STORAGE_FAILURE)
|
||||||
|
if not self.repository.compare_and_swap(waiting, events=[{
|
||||||
|
"event": event,
|
||||||
|
"code": ErrorCode.STORAGE_FAILURE.value,
|
||||||
|
"message": message[:1000],
|
||||||
|
}]):
|
||||||
|
return Rejected(self._stale())
|
||||||
|
return Rejected(WorkflowError(
|
||||||
|
ErrorCode.STORAGE_FAILURE,
|
||||||
|
"Requirements artifact storage is temporarily unavailable; the task can be resumed.",
|
||||||
|
retryable=True,
|
||||||
|
))
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"""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,62 @@
|
|||||||
|
"""Protocol v3 composition root. This is the only layer joining adapters."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from app.cad_agent.adapters.artifact_store import FileArtifactStore
|
||||||
|
from app.cad_agent.adapters.event_publisher import IdempotentInProcessPublisher
|
||||||
|
from app.cad_agent.adapters.runtime import ProfileCadRuntime
|
||||||
|
from app.cad_agent.adapters.review_gateway import RenderedReviewGateway
|
||||||
|
from app.cad_agent.adapters.sqlite_repository import SqliteTaskRepository
|
||||||
|
from app.cad_agent.adapters.structured_llm import StructuredModelGateway
|
||||||
|
from app.cad_agent.adapters.verifier import RegistryVerifierExecutor
|
||||||
|
from app.cad_agent.application.action_handlers import ActionCommandHandler
|
||||||
|
from app.cad_agent.application.outbox import OutboxDispatcher
|
||||||
|
from app.cad_agent.application.requirements_review import RequirementsCommandHandler
|
||||||
|
from app.cad_agent.application.workflow import ModelIdentity, WorkflowConfig, WorkflowCoordinator
|
||||||
|
from app.cad_agent.domain.verifier_registry import default_registry
|
||||||
|
from app.settings import Settings
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class V3Services:
|
||||||
|
repository: SqliteTaskRepository
|
||||||
|
artifacts: FileArtifactStore
|
||||||
|
workflow: WorkflowCoordinator
|
||||||
|
models: StructuredModelGateway
|
||||||
|
outbox: OutboxDispatcher
|
||||||
|
|
||||||
|
|
||||||
|
def compose_v3(settings: Settings) -> V3Services:
|
||||||
|
repository = SqliteTaskRepository(settings.task_root.parent / "autonomous-cad-v3.sqlite3")
|
||||||
|
artifacts = FileArtifactStore(settings.task_root)
|
||||||
|
runtime = ProfileCadRuntime(settings)
|
||||||
|
registry = default_registry()
|
||||||
|
verifier = RegistryVerifierExecutor(registry)
|
||||||
|
requirements = RequirementsCommandHandler(repository, artifacts, registry)
|
||||||
|
actions = ActionCommandHandler(repository, artifacts, runtime, verifier)
|
||||||
|
fallbacks = tuple(
|
||||||
|
ModelIdentity(provider.id, model.id)
|
||||||
|
for provider in settings.providers
|
||||||
|
if provider.configured
|
||||||
|
for model in provider.models[:1]
|
||||||
|
)
|
||||||
|
models = StructuredModelGateway(settings)
|
||||||
|
outbox = OutboxDispatcher(repository, IdempotentInProcessPublisher())
|
||||||
|
workflow = WorkflowCoordinator(
|
||||||
|
WorkflowConfig(
|
||||||
|
max_turns=max(8, settings.agent_tool_calls_per_cycle * 8),
|
||||||
|
format_error_limit=settings.agent_format_error_repeat_limit,
|
||||||
|
requirements_review_limit=3,
|
||||||
|
author_fallbacks=fallbacks,
|
||||||
|
),
|
||||||
|
repository,
|
||||||
|
artifacts,
|
||||||
|
runtime,
|
||||||
|
models,
|
||||||
|
RenderedReviewGateway(models),
|
||||||
|
requirements,
|
||||||
|
actions,
|
||||||
|
)
|
||||||
|
return V3Services(repository, artifacts, workflow, models, outbox)
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
"""Pure domain objects and policies for protocol v3."""
|
||||||
|
|
||||||
|
from .errors import ErrorCode, WorkflowError
|
||||||
|
from .state import TaskPhase, TaskState, transition
|
||||||
|
|
||||||
|
__all__ = ["ErrorCode", "TaskPhase", "TaskState", "WorkflowError", "transition"]
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
"""Canonical partial matching for acceptance-claim expectations."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
def contains_expected(actual: Any, required: Any) -> bool:
|
||||||
|
"""Allow an actual claim to add provider-chosen optional fields only."""
|
||||||
|
if isinstance(required, dict):
|
||||||
|
return isinstance(actual, dict) and all(
|
||||||
|
key in actual and contains_expected(actual[key], value)
|
||||||
|
for key, value in required.items()
|
||||||
|
)
|
||||||
|
if isinstance(required, list):
|
||||||
|
return isinstance(actual, list) and len(actual) == len(required) and all(
|
||||||
|
contains_expected(actual_item, required_item)
|
||||||
|
for actual_item, required_item in zip(actual, required, strict=True)
|
||||||
|
)
|
||||||
|
return actual == required
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
"""Typed, serializable failures used across the v3 workflow."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from enum import StrEnum
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
class ErrorCode(StrEnum):
|
||||||
|
CANCELLED = "CANCELLED"
|
||||||
|
AUTHOR_FORMAT_INVALID = "AUTHOR_FORMAT_INVALID"
|
||||||
|
AUTHOR_DECISION_REJECTED = "AUTHOR_DECISION_REJECTED"
|
||||||
|
STALE_WORKING_HEAD = "STALE_WORKING_HEAD"
|
||||||
|
FAILED_AUTHOR_FORMAT = "FAILED_AUTHOR_FORMAT"
|
||||||
|
RUNTIME_PRECONDITION_FAILED = "RUNTIME_PRECONDITION_FAILED"
|
||||||
|
RUNTIME_CONTRACT_INVALID = "RUNTIME_CONTRACT_INVALID"
|
||||||
|
CANDIDATE_BUILD_FAILED = "CANDIDATE_BUILD_FAILED"
|
||||||
|
CANDIDATE_REVIEW_REJECTED = "CANDIDATE_REVIEW_REJECTED"
|
||||||
|
VERIFIER_UNAVAILABLE = "VERIFIER_UNAVAILABLE"
|
||||||
|
CLAIM_VERIFICATION_FAILED = "CLAIM_VERIFICATION_FAILED"
|
||||||
|
MODEL_STRUCTURED_OUTPUT_UNSUPPORTED = "MODEL_STRUCTURED_OUTPUT_UNSUPPORTED"
|
||||||
|
AUTHOR_TRANSPORT_UNAVAILABLE = "AUTHOR_TRANSPORT_UNAVAILABLE"
|
||||||
|
REVIEW_SERVICE_UNAVAILABLE = "REVIEW_SERVICE_UNAVAILABLE"
|
||||||
|
RENDER_SERVICE_UNAVAILABLE = "RENDER_SERVICE_UNAVAILABLE"
|
||||||
|
STORAGE_FAILURE = "STORAGE_FAILURE"
|
||||||
|
CALL_BUDGET_EXHAUSTED = "CALL_BUDGET_EXHAUSTED"
|
||||||
|
REQUIREMENTS_REVIEW_NOT_CONVERGED = "REQUIREMENTS_REVIEW_NOT_CONVERGED"
|
||||||
|
NO_PROGRESS_LIMIT = "NO_PROGRESS_LIMIT"
|
||||||
|
RUNTIME_EXECUTION_FAILURE = "RUNTIME_EXECUTION_FAILURE"
|
||||||
|
FAILED_INTERNAL = "FAILED_INTERNAL"
|
||||||
|
WAITING_FOR_USER = "WAITING_FOR_USER"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class WorkflowError:
|
||||||
|
"""An expected domain rejection, never a free-form exception channel."""
|
||||||
|
|
||||||
|
code: ErrorCode
|
||||||
|
message: str
|
||||||
|
field_errors: tuple[dict[str, Any], ...] = ()
|
||||||
|
retryable: bool = False
|
||||||
|
details: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def payload(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"code": self.code.value,
|
||||||
|
"message": self.message,
|
||||||
|
"field_errors": list(self.field_errors),
|
||||||
|
"retryable": self.retryable,
|
||||||
|
"details": self.details,
|
||||||
|
}
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
"""Versioned runtime operation contracts and dynamic fragment schemas."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from copy import deepcopy
|
||||||
|
from hashlib import sha256
|
||||||
|
import json
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from jsonschema import Draft202012Validator
|
||||||
|
from jsonschema.exceptions import SchemaError
|
||||||
|
|
||||||
|
|
||||||
|
class OperationContractError(ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
SEMANTIC_PREFLIGHT_NAMES = frozenset({
|
||||||
|
"sketch_workplane",
|
||||||
|
"profile_non_self_intersecting",
|
||||||
|
"host_face_exists",
|
||||||
|
"hole_positions_on_host_plane",
|
||||||
|
"cut_exit_distance",
|
||||||
|
"requires_active_solid",
|
||||||
|
"revolve_axis_on_sketch",
|
||||||
|
"reference_plane_nonzero_normal",
|
||||||
|
"reference_axis_nonzero_direction",
|
||||||
|
"selected_edges_exist",
|
||||||
|
"source_features_exist",
|
||||||
|
"mirror_plane_exists",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def canonical_hash(value: dict[str, Any]) -> str:
|
||||||
|
return sha256(json.dumps(value, ensure_ascii=True, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _is_closed(schema: Any) -> bool:
|
||||||
|
if not isinstance(schema, dict):
|
||||||
|
return False
|
||||||
|
if schema.get("type") == "object" and schema.get("additionalProperties") is not False:
|
||||||
|
return False
|
||||||
|
for name in ("properties", "$defs", "definitions"):
|
||||||
|
values = schema.get(name)
|
||||||
|
if isinstance(values, dict) and not all(_is_closed(value) for value in values.values()):
|
||||||
|
return False
|
||||||
|
for name in ("items", "additionalItems"):
|
||||||
|
value = schema.get(name)
|
||||||
|
if isinstance(value, dict) and not _is_closed(value):
|
||||||
|
return False
|
||||||
|
for name in ("oneOf", "anyOf", "allOf"):
|
||||||
|
values = schema.get(name)
|
||||||
|
if values is not None and (not isinstance(values, list) or not all(_is_closed(value) for value in values)):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def validate_operation_contract(contract: dict[str, Any]) -> None:
|
||||||
|
required = {
|
||||||
|
"atomic_id", "contract_version", "fragment_shape", "author_params_schema", "selector_policy",
|
||||||
|
"server_injected_paths", "reference_policy", "semantic_preflight", "candidate_verifiers",
|
||||||
|
}
|
||||||
|
if not required.issubset(contract) or set(contract) - required - {"contract_hash", "registry_revision"}:
|
||||||
|
raise OperationContractError("Operation contract has unknown or missing fields")
|
||||||
|
if not isinstance(contract["atomic_id"], str) or not contract["atomic_id"]:
|
||||||
|
raise OperationContractError("Operation contract atomic_id is invalid")
|
||||||
|
if not isinstance(contract["contract_version"], str) or not contract["contract_version"]:
|
||||||
|
raise OperationContractError("Operation contract version is missing")
|
||||||
|
shape = contract["fragment_shape"]
|
||||||
|
if not isinstance(shape, dict) or set(shape) != {"sketch", "params", "selector_tokens"}:
|
||||||
|
raise OperationContractError("Operation contract fragment_shape is invalid")
|
||||||
|
if shape["sketch"] not in {"required", "forbidden"} or shape["params"] != "required_object" or shape["selector_tokens"] not in {"required", "forbidden"}:
|
||||||
|
raise OperationContractError("Operation contract fragment_shape is unsupported")
|
||||||
|
params = contract["author_params_schema"]
|
||||||
|
if not _is_closed(params):
|
||||||
|
raise OperationContractError("Operation author_params_schema must recursively close all objects")
|
||||||
|
try:
|
||||||
|
Draft202012Validator.check_schema(params)
|
||||||
|
except SchemaError as error:
|
||||||
|
raise OperationContractError("Operation author_params_schema is invalid JSON Schema") from error
|
||||||
|
selector = contract["selector_policy"]
|
||||||
|
if not isinstance(selector, dict) or set(selector) != {"slot", "token_kind", "min_items", "max_items", "snapshot_bound"}:
|
||||||
|
raise OperationContractError("Operation selector_policy is invalid")
|
||||||
|
if shape["selector_tokens"] == "forbidden":
|
||||||
|
if any(selector[key] not in {None, 0, False, ""} for key in selector):
|
||||||
|
raise OperationContractError("Selector policy must be empty when selectors are forbidden")
|
||||||
|
else:
|
||||||
|
if not isinstance(selector["slot"], str) or not selector["slot"] or selector["token_kind"] not in {"face", "edge", "plane", "axis", "body"}:
|
||||||
|
raise OperationContractError("Selector policy has no concrete slot/token kind")
|
||||||
|
if not isinstance(selector["min_items"], int) or not isinstance(selector["max_items"], int) or not 1 <= selector["min_items"] <= selector["max_items"] <= 64 or selector["snapshot_bound"] is not True:
|
||||||
|
raise OperationContractError("Selector policy bounds are invalid")
|
||||||
|
if not isinstance(contract["server_injected_paths"], list) or not all(isinstance(item, str) and item for item in contract["server_injected_paths"]):
|
||||||
|
raise OperationContractError("Operation server injection paths are invalid")
|
||||||
|
injected = contract["server_injected_paths"]
|
||||||
|
expected_injected = [selector["slot"]] if shape["selector_tokens"] == "required" else []
|
||||||
|
if injected != expected_injected:
|
||||||
|
raise OperationContractError("Operation selector policy and server injection paths disagree")
|
||||||
|
if shape["selector_tokens"] == "required" and selector["slot"].startswith("params."):
|
||||||
|
injected_param = selector["slot"].removeprefix("params.")
|
||||||
|
properties = params.get("properties") if isinstance(params.get("properties"), dict) else {}
|
||||||
|
if injected_param in properties:
|
||||||
|
raise OperationContractError("Server-injected selector must not be author-owned")
|
||||||
|
reference = contract["reference_policy"]
|
||||||
|
if not isinstance(reference, dict) or reference.get("mode") not in {"none", "snapshot_bound"}:
|
||||||
|
raise OperationContractError("Operation reference policy is invalid")
|
||||||
|
if reference["mode"] == "none" and set(reference) != {"mode"}:
|
||||||
|
raise OperationContractError("Reference policy must be empty when references are forbidden")
|
||||||
|
if reference["mode"] == "snapshot_bound":
|
||||||
|
if set(reference) != {"mode", "slot", "token_kind", "min_items", "max_items", "snapshot_bound"}:
|
||||||
|
raise OperationContractError("Snapshot-bound reference policy is invalid")
|
||||||
|
if not isinstance(reference["slot"], str) or not reference["slot"].startswith("params.") or reference["token_kind"] != "feature" or not isinstance(reference["min_items"], int) or not isinstance(reference["max_items"], int) or not 1 <= reference["min_items"] <= reference["max_items"] <= 64 or reference["snapshot_bound"] is not True:
|
||||||
|
raise OperationContractError("Snapshot-bound reference policy has invalid bounds")
|
||||||
|
reference_name = reference["slot"].removeprefix("params.")
|
||||||
|
properties = params.get("properties") if isinstance(params.get("properties"), dict) else {}
|
||||||
|
reference_schema = properties.get(reference_name)
|
||||||
|
required_params = params.get("required") if isinstance(params.get("required"), list) else []
|
||||||
|
if (
|
||||||
|
not isinstance(reference_schema, dict)
|
||||||
|
or reference_schema.get("type") != "array"
|
||||||
|
or not isinstance(reference_schema.get("items"), dict)
|
||||||
|
or reference_name not in required_params
|
||||||
|
):
|
||||||
|
raise OperationContractError("Snapshot-bound reference slot must be a required author array")
|
||||||
|
if not isinstance(contract["semantic_preflight"], list) or not all(isinstance(item, str) and item for item in contract["semantic_preflight"]):
|
||||||
|
raise OperationContractError("Operation semantic preflight is invalid")
|
||||||
|
if len(set(contract["semantic_preflight"])) != len(contract["semantic_preflight"]) or set(contract["semantic_preflight"]) - SEMANTIC_PREFLIGHT_NAMES:
|
||||||
|
raise OperationContractError("Operation semantic preflight names are unknown or duplicated")
|
||||||
|
if not isinstance(contract["candidate_verifiers"], list) or not all(isinstance(item, str) and item for item in contract["candidate_verifiers"]):
|
||||||
|
raise OperationContractError("Operation candidate_verifiers are invalid")
|
||||||
|
if len(set(contract["candidate_verifiers"])) != len(contract["candidate_verifiers"]):
|
||||||
|
raise OperationContractError("Operation candidate verifiers are duplicated")
|
||||||
|
|
||||||
|
|
||||||
|
def fragment_schema(contract: dict[str, Any], *, selector_tokens: list[str], reference_tokens: list[str] | None = None) -> dict[str, Any]:
|
||||||
|
"""Build the one-operation schema exposed for one pending action."""
|
||||||
|
validate_operation_contract(contract)
|
||||||
|
shape = contract["fragment_shape"]
|
||||||
|
params = deepcopy(contract["author_params_schema"])
|
||||||
|
reference = contract["reference_policy"]
|
||||||
|
if reference["mode"] == "snapshot_bound":
|
||||||
|
slot = str(reference["slot"]).removeprefix("params.")
|
||||||
|
items = params.get("properties", {}).get(slot, {}).get("items") if isinstance(params.get("properties"), dict) else None
|
||||||
|
if not isinstance(items, dict):
|
||||||
|
raise OperationContractError("Snapshot-bound reference slot is absent from author params schema")
|
||||||
|
items.clear()
|
||||||
|
items.update({"enum": reference_tokens or []})
|
||||||
|
feature_properties: dict[str, Any] = {
|
||||||
|
"atomic_id": {"const": contract["atomic_id"]},
|
||||||
|
"params": params,
|
||||||
|
}
|
||||||
|
feature_required = ["atomic_id", "params"]
|
||||||
|
if shape["selector_tokens"] == "required":
|
||||||
|
policy = contract["selector_policy"]
|
||||||
|
feature_properties["selector_tokens"] = {
|
||||||
|
"type": "array", "items": {"enum": selector_tokens}, "minItems": policy["min_items"],
|
||||||
|
"maxItems": policy["max_items"], "uniqueItems": True,
|
||||||
|
"description": (
|
||||||
|
"Required author input. Copy the opaque selector token returned by the current topology "
|
||||||
|
"snapshot here; do not omit it and do not put a host face in params. The server resolves this "
|
||||||
|
"token into the host selector after schema validation."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
feature_required.append("selector_tokens")
|
||||||
|
feature = {
|
||||||
|
"type": "object",
|
||||||
|
"description": (
|
||||||
|
"One atomic feature. selector_tokens, when present, is an author-supplied topology token array "
|
||||||
|
"rather than a server-filled params field."
|
||||||
|
),
|
||||||
|
"properties": feature_properties,
|
||||||
|
"required": feature_required,
|
||||||
|
"additionalProperties": False,
|
||||||
|
}
|
||||||
|
properties: dict[str, Any] = {"feature": feature}
|
||||||
|
required = ["feature"]
|
||||||
|
if shape["sketch"] == "required":
|
||||||
|
properties["sketch"] = _sketch_schema()
|
||||||
|
required.insert(0, "sketch")
|
||||||
|
schema = {"$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "properties": properties, "required": required, "additionalProperties": False}
|
||||||
|
Draft202012Validator.check_schema(schema)
|
||||||
|
return schema
|
||||||
|
|
||||||
|
|
||||||
|
def validate_fragment(contract: dict[str, Any], fragment: Any, *, selector_tokens: list[str], reference_tokens: list[str] | None = None) -> list[dict[str, str]]:
|
||||||
|
schema = fragment_schema(contract, selector_tokens=selector_tokens, reference_tokens=reference_tokens)
|
||||||
|
return [
|
||||||
|
{"path": "/" + "/".join(str(part) for part in error.absolute_path), "message": error.message}
|
||||||
|
for error in sorted(Draft202012Validator(schema).iter_errors(fragment), key=lambda item: (list(item.absolute_path), item.message))
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _point(size: int) -> dict[str, Any]:
|
||||||
|
return {"type": "array", "items": {"type": "number"}, "minItems": size, "maxItems": size}
|
||||||
|
|
||||||
|
|
||||||
|
def _sketch_schema() -> dict[str, Any]:
|
||||||
|
point2 = _point(2)
|
||||||
|
point3 = _point(3)
|
||||||
|
workplane = {"type": "object", "properties": {"origin_mm": point3, "x_dir": deepcopy(point3), "normal": deepcopy(point3)}, "required": ["origin_mm", "x_dir", "normal"], "additionalProperties": False}
|
||||||
|
profile = {
|
||||||
|
"oneOf": [
|
||||||
|
{"type": "object", "properties": {"type": {"const": "circle"}, "center": deepcopy(point2), "radius_mm": {"type": "number", "exclusiveMinimum": 0}}, "required": ["type", "radius_mm"], "additionalProperties": False},
|
||||||
|
{"type": "object", "properties": {"type": {"const": "polygon"}, "vertices": {"type": "array", "minItems": 3, "items": deepcopy(point2)}}, "required": ["type", "vertices"], "additionalProperties": False},
|
||||||
|
{"type": "object", "properties": {"type": {"const": "analytic_contours"}, "contours": {"type": "array", "minItems": 1, "maxItems": 8, "items": {"type": "object", "properties": {"role": {"enum": ["outer", "inner"]}, "closed": {"const": True}, "segments": {"type": "array", "minItems": 1, "items": {"oneOf": [{"type": "object", "properties": {"type": {"const": "line"}, "start": deepcopy(point2), "end": deepcopy(point2)}, "required": ["type", "start", "end"], "additionalProperties": False}, {"type": "object", "properties": {"type": {"const": "circle"}, "center": deepcopy(point2), "radius_mm": {"type": "number", "exclusiveMinimum": 0}}, "required": ["type", "center", "radius_mm"], "additionalProperties": False}, {"type": "object", "properties": {"type": {"const": "arc"}, "start": deepcopy(point2), "end": deepcopy(point2), "center": deepcopy(point2), "radius_mm": {"type": "number", "exclusiveMinimum": 0}, "clockwise": {"type": "boolean"}}, "required": ["type", "start", "end", "center", "radius_mm"], "additionalProperties": False}]}}}, "required": ["role", "closed", "segments"], "additionalProperties": False}}}, "required": ["type", "contours"], "additionalProperties": False},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
return {"type": "object", "properties": {"workplane": workplane, "profile": profile}, "required": ["workplane", "profile"], "additionalProperties": False}
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
"""Finite workflow state machine. This module has no persistence imports."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, replace
|
||||||
|
from enum import StrEnum
|
||||||
|
|
||||||
|
from .errors import ErrorCode, WorkflowError
|
||||||
|
|
||||||
|
|
||||||
|
class TaskPhase(StrEnum):
|
||||||
|
DRAFTING_REQUIREMENTS = "DRAFTING_REQUIREMENTS"
|
||||||
|
REVIEWING_REQUIREMENTS = "REVIEWING_REQUIREMENTS"
|
||||||
|
AWAITING_ACTION = "AWAITING_ACTION"
|
||||||
|
ACTION_PENDING = "ACTION_PENDING"
|
||||||
|
CANDIDATE_BUILDING = "CANDIDATE_BUILDING"
|
||||||
|
CANDIDATE_REVIEW = "CANDIDATE_REVIEW"
|
||||||
|
FINAL_VALIDATION = "FINAL_VALIDATION"
|
||||||
|
WAITING_RETRY = "WAITING_RETRY"
|
||||||
|
WAITING_FOR_USER = "WAITING_FOR_USER"
|
||||||
|
CANCELLED = "CANCELLED"
|
||||||
|
COMPLETED = "COMPLETED"
|
||||||
|
FAILED = "FAILED"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class PendingAction:
|
||||||
|
action_id: str
|
||||||
|
working_head: str
|
||||||
|
intent: str
|
||||||
|
requirement_ids: tuple[str, ...]
|
||||||
|
atomic_id: str
|
||||||
|
expected_change: str
|
||||||
|
contract_hash: str
|
||||||
|
idempotency_key: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class TaskState:
|
||||||
|
task_id: str
|
||||||
|
phase: TaskPhase
|
||||||
|
version: int
|
||||||
|
active_revision: str = ""
|
||||||
|
pending_action: PendingAction | None = None
|
||||||
|
candidate_id: str = ""
|
||||||
|
candidate_stage_id: str = ""
|
||||||
|
repair_required: bool = False
|
||||||
|
last_error: ErrorCode | None = None
|
||||||
|
retry_from_phase: TaskPhase | None = None
|
||||||
|
requirements_draft_path: str = ""
|
||||||
|
requirements_review_path: str = ""
|
||||||
|
requirements_contract_path: str = ""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def working_head(self) -> str:
|
||||||
|
return f"{self.task_id}:{self.active_revision or 'root'}:v{self.version}"
|
||||||
|
|
||||||
|
|
||||||
|
# Legal state transitions. Events are intentionally terse persistence-neutral
|
||||||
|
# names used by command handlers and architecture tests.
|
||||||
|
_TRANSITIONS: dict[tuple[TaskPhase, str], TaskPhase] = {
|
||||||
|
(TaskPhase.DRAFTING_REQUIREMENTS, "draft_updated"): TaskPhase.DRAFTING_REQUIREMENTS,
|
||||||
|
(TaskPhase.DRAFTING_REQUIREMENTS, "requirements_finalized"): TaskPhase.REVIEWING_REQUIREMENTS,
|
||||||
|
(TaskPhase.REVIEWING_REQUIREMENTS, "requirements_approved"): TaskPhase.AWAITING_ACTION,
|
||||||
|
(TaskPhase.REVIEWING_REQUIREMENTS, "requirements_revise"): TaskPhase.DRAFTING_REQUIREMENTS,
|
||||||
|
(TaskPhase.REVIEWING_REQUIREMENTS, "waiting_for_user"): TaskPhase.WAITING_FOR_USER,
|
||||||
|
# User clarifications are durable task evidence. Requirement-level pauses
|
||||||
|
# resume on the same task so the frozen request and draft history remain
|
||||||
|
# reviewable instead of turning a short reply into a new CAD request.
|
||||||
|
(TaskPhase.WAITING_FOR_USER, "requirements_clarified"): TaskPhase.DRAFTING_REQUIREMENTS,
|
||||||
|
(TaskPhase.AWAITING_ACTION, "action_proposed"): TaskPhase.ACTION_PENDING,
|
||||||
|
(TaskPhase.AWAITING_ACTION, "diagnosis_recorded"): TaskPhase.AWAITING_ACTION,
|
||||||
|
(TaskPhase.AWAITING_ACTION, "rollback"): TaskPhase.AWAITING_ACTION,
|
||||||
|
(TaskPhase.ACTION_PENDING, "candidate_started"): TaskPhase.CANDIDATE_BUILDING,
|
||||||
|
# Runtime semantic preflight happens before candidate staging. It must
|
||||||
|
# leave the checkpoint untouched and return control to the author for a
|
||||||
|
# fresh observation/action decision, never masquerade as a format error.
|
||||||
|
(TaskPhase.ACTION_PENDING, "runtime_precondition_rejected"): TaskPhase.AWAITING_ACTION,
|
||||||
|
(TaskPhase.ACTION_PENDING, "diagnosis_recorded"): TaskPhase.ACTION_PENDING,
|
||||||
|
(TaskPhase.ACTION_PENDING, "diagnosis_return_to_action_selection"): TaskPhase.AWAITING_ACTION,
|
||||||
|
(TaskPhase.CANDIDATE_BUILDING, "candidate_built"): TaskPhase.CANDIDATE_REVIEW,
|
||||||
|
(TaskPhase.CANDIDATE_BUILDING, "candidate_rejected"): TaskPhase.ACTION_PENDING,
|
||||||
|
(TaskPhase.CANDIDATE_REVIEW, "candidate_accepted"): TaskPhase.AWAITING_ACTION,
|
||||||
|
(TaskPhase.CANDIDATE_REVIEW, "candidate_rejected"): TaskPhase.AWAITING_ACTION,
|
||||||
|
(TaskPhase.AWAITING_ACTION, "final_requested"): TaskPhase.FINAL_VALIDATION,
|
||||||
|
(TaskPhase.FINAL_VALIDATION, "final_accepted"): TaskPhase.COMPLETED,
|
||||||
|
(TaskPhase.FINAL_VALIDATION, "final_repair"): TaskPhase.AWAITING_ACTION,
|
||||||
|
}
|
||||||
|
_TRANSITIONS.update({
|
||||||
|
(phase, "failed"): TaskPhase.FAILED
|
||||||
|
for phase in TaskPhase
|
||||||
|
if phase not in {TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED}
|
||||||
|
})
|
||||||
|
_TRANSITIONS.update({
|
||||||
|
(phase, "cancelled"): TaskPhase.CANCELLED
|
||||||
|
for phase in TaskPhase
|
||||||
|
if phase not in {TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED}
|
||||||
|
})
|
||||||
|
_RETRY_RESUMABLE_PHASES = frozenset({
|
||||||
|
TaskPhase.DRAFTING_REQUIREMENTS,
|
||||||
|
TaskPhase.REVIEWING_REQUIREMENTS,
|
||||||
|
TaskPhase.AWAITING_ACTION,
|
||||||
|
TaskPhase.ACTION_PENDING,
|
||||||
|
TaskPhase.CANDIDATE_BUILDING,
|
||||||
|
TaskPhase.CANDIDATE_REVIEW,
|
||||||
|
TaskPhase.FINAL_VALIDATION,
|
||||||
|
})
|
||||||
|
_TRANSITIONS.update({
|
||||||
|
(TaskPhase.WAITING_RETRY, f"resume_{phase.value.lower()}"): phase
|
||||||
|
for phase in _RETRY_RESUMABLE_PHASES
|
||||||
|
})
|
||||||
|
_TRANSITIONS.update({
|
||||||
|
(phase, "waiting_retry"): TaskPhase.WAITING_RETRY
|
||||||
|
for phase in TaskPhase
|
||||||
|
if phase not in {TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED, TaskPhase.WAITING_RETRY}
|
||||||
|
})
|
||||||
|
def legal_transitions() -> dict[tuple[TaskPhase, str], TaskPhase]:
|
||||||
|
"""Return a copy of the protocol transition table for architecture tests."""
|
||||||
|
return dict(_TRANSITIONS)
|
||||||
|
|
||||||
|
|
||||||
|
def retry_resume_event(state: TaskState) -> str | None:
|
||||||
|
"""Return the only lossless resume event for a parked service failure."""
|
||||||
|
if state.phase != TaskPhase.WAITING_RETRY or state.retry_from_phase not in _RETRY_RESUMABLE_PHASES:
|
||||||
|
return None
|
||||||
|
return f"resume_{state.retry_from_phase.value.lower()}"
|
||||||
|
|
||||||
|
|
||||||
|
def transition(state: TaskState, event: str, *, pending_action: PendingAction | None | object = ..., active_revision: str | None = None, candidate_id: str | None = None, candidate_stage_id: str | None = None, repair_required: bool | None = None, error: ErrorCode | None = None, requirements_draft_path: str | None = None, requirements_review_path: str | None = None, requirements_contract_path: str | None = None) -> TaskState:
|
||||||
|
"""Apply one legal transition and advance optimistic-concurrency version."""
|
||||||
|
target = _TRANSITIONS.get((state.phase, event))
|
||||||
|
if target is None:
|
||||||
|
raise ValueError(f"Illegal v3 transition: {state.phase.value} --{event}--> ?")
|
||||||
|
if state.phase == TaskPhase.WAITING_RETRY and event.startswith("resume_") and state.retry_from_phase != target:
|
||||||
|
raise ValueError("WAITING_RETRY resume event does not match its persisted source phase")
|
||||||
|
next_pending = state.pending_action if pending_action is ... else pending_action
|
||||||
|
if target in {TaskPhase.AWAITING_ACTION, TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED}:
|
||||||
|
next_pending = None
|
||||||
|
return replace(
|
||||||
|
state,
|
||||||
|
phase=target,
|
||||||
|
version=state.version + 1,
|
||||||
|
active_revision=state.active_revision if active_revision is None else active_revision,
|
||||||
|
pending_action=next_pending,
|
||||||
|
candidate_id="" if target in {TaskPhase.AWAITING_ACTION, TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED} else state.candidate_id if candidate_id is None else candidate_id,
|
||||||
|
candidate_stage_id="" if target in {TaskPhase.AWAITING_ACTION, TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED} else state.candidate_stage_id if candidate_stage_id is None else candidate_stage_id,
|
||||||
|
repair_required=state.repair_required if repair_required is None else repair_required,
|
||||||
|
last_error=error,
|
||||||
|
retry_from_phase=state.phase if target == TaskPhase.WAITING_RETRY else None,
|
||||||
|
requirements_draft_path=state.requirements_draft_path if requirements_draft_path is None else requirements_draft_path,
|
||||||
|
requirements_review_path=state.requirements_review_path if requirements_review_path is None else requirements_review_path,
|
||||||
|
requirements_contract_path=state.requirements_contract_path if requirements_contract_path is None else requirements_contract_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def reject_stale_head(state: TaskState, supplied_head: str) -> WorkflowError | None:
|
||||||
|
if supplied_head != state.working_head:
|
||||||
|
return WorkflowError(
|
||||||
|
ErrorCode.STALE_WORKING_HEAD,
|
||||||
|
"The command was bound to an obsolete working head.",
|
||||||
|
details={"expected_working_head": state.working_head, "supplied_working_head": supplied_head},
|
||||||
|
)
|
||||||
|
return None
|
||||||
@@ -0,0 +1,871 @@
|
|||||||
|
"""Deterministic acceptance-claim registry and closed-schema validation."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from math import atan2, isclose, pi, sqrt
|
||||||
|
from typing import Any, Callable
|
||||||
|
|
||||||
|
from jsonschema import Draft202012Validator
|
||||||
|
from jsonschema.exceptions import SchemaError
|
||||||
|
|
||||||
|
|
||||||
|
ClaimResult = dict[str, Any]
|
||||||
|
ClaimEvaluator = Callable[[dict[str, Any], dict[str, Any]], ClaimResult]
|
||||||
|
|
||||||
|
|
||||||
|
def _closed_object(properties: dict[str, Any], required: list[str]) -> dict[str, Any]:
|
||||||
|
return {"type": "object", "properties": properties, "required": required, "additionalProperties": False}
|
||||||
|
|
||||||
|
|
||||||
|
def _vector(value: Any) -> tuple[float, float, float] | None:
|
||||||
|
if not isinstance(value, list) or len(value) != 3:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return tuple(float(item) for item in value) # type: ignore[return-value]
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _cylinder_axis_point(record: dict[str, Any]) -> tuple[float, float, float] | None:
|
||||||
|
"""Return an actual point on a cylinder's axis, never a surface centroid."""
|
||||||
|
geometry = record.get("geometry") if isinstance(record.get("geometry"), dict) else {}
|
||||||
|
return _vector(geometry.get("axis_origin_mm")) or _vector(geometry.get("center_mm"))
|
||||||
|
|
||||||
|
|
||||||
|
def _cylinder_axis_direction(record: dict[str, Any]) -> tuple[float, float, float] | None:
|
||||||
|
geometry = record.get("geometry") if isinstance(record.get("geometry"), dict) else {}
|
||||||
|
direction = _vector(geometry.get("axis_direction"))
|
||||||
|
if direction is None:
|
||||||
|
return None
|
||||||
|
length = sqrt(sum(component * component for component in direction))
|
||||||
|
return tuple(component / length for component in direction) if length > 1e-9 else None
|
||||||
|
|
||||||
|
|
||||||
|
def _cylinder_axis_span(record: dict[str, Any]) -> float | None:
|
||||||
|
"""Return the axial span of a cylinder face from its B-rep bounding box."""
|
||||||
|
interval = _cylinder_axis_interval(record)
|
||||||
|
return None if interval is None else interval[1] - interval[0]
|
||||||
|
|
||||||
|
|
||||||
|
def _cylinder_axis_interval(record: dict[str, Any]) -> tuple[float, float] | None:
|
||||||
|
"""Return the inclusive axial interval of a cylindrical face's bounds."""
|
||||||
|
geometry = record.get("geometry") if isinstance(record.get("geometry"), dict) else {}
|
||||||
|
bbox = geometry.get("bbox_mm")
|
||||||
|
direction = _cylinder_axis_direction(record)
|
||||||
|
if not isinstance(bbox, list) or len(bbox) != 6 or direction is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
minimum = tuple(float(value) for value in bbox[:3])
|
||||||
|
maximum = tuple(float(value) for value in bbox[3:])
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
projections = [
|
||||||
|
sum(direction[index] * point[index] for index in range(3))
|
||||||
|
for point in (
|
||||||
|
(x, y, z)
|
||||||
|
for x in (minimum[0], maximum[0])
|
||||||
|
for y in (minimum[1], maximum[1])
|
||||||
|
for z in (minimum[2], maximum[2])
|
||||||
|
)
|
||||||
|
]
|
||||||
|
return min(projections), max(projections)
|
||||||
|
|
||||||
|
|
||||||
|
def _records(facts: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
topology = facts.get("topology") if isinstance(facts.get("topology"), dict) else {}
|
||||||
|
return [item for item in topology.get("records") or () if isinstance(item, dict)]
|
||||||
|
|
||||||
|
|
||||||
|
def _pass(evidence: dict[str, Any]) -> ClaimResult:
|
||||||
|
return {"status": "pass", "evidence": evidence}
|
||||||
|
|
||||||
|
|
||||||
|
def _fail(evidence: dict[str, Any]) -> ClaimResult:
|
||||||
|
return {"status": "fail", "evidence": evidence}
|
||||||
|
|
||||||
|
|
||||||
|
def _pending(reason: str) -> ClaimResult:
|
||||||
|
return {"status": "pending", "evidence": {"reason": reason}}
|
||||||
|
|
||||||
|
|
||||||
|
def _solid_count(expected: dict[str, Any], facts: dict[str, Any]) -> ClaimResult:
|
||||||
|
health = facts.get("health") if isinstance(facts.get("health"), dict) else {}
|
||||||
|
actual = health.get("solid_count")
|
||||||
|
if not isinstance(actual, int):
|
||||||
|
return _pending("rebuild report has no solid_count")
|
||||||
|
return _pass({"solid_count": actual}) if actual == expected["value"] else _fail({"expected": expected["value"], "actual": actual})
|
||||||
|
|
||||||
|
|
||||||
|
def _volume_decreased(_expected: dict[str, Any], facts: dict[str, Any]) -> ClaimResult:
|
||||||
|
"""Prove that a subtractive operation removed a measurable amount of material."""
|
||||||
|
health = facts.get("health") if isinstance(facts.get("health"), dict) else {}
|
||||||
|
parent_health = facts.get("parent_health") if isinstance(facts.get("parent_health"), dict) else {}
|
||||||
|
parent_volume = parent_health.get("volume_mm3")
|
||||||
|
candidate_volume = health.get("volume_mm3")
|
||||||
|
if not isinstance(parent_volume, (int, float)) or not isinstance(candidate_volume, (int, float)):
|
||||||
|
return _pending("parent or candidate rebuild report has no measurable volume")
|
||||||
|
removed = float(parent_volume) - float(candidate_volume)
|
||||||
|
evidence = {
|
||||||
|
"parent_volume_mm3": float(parent_volume),
|
||||||
|
"candidate_volume_mm3": float(candidate_volume),
|
||||||
|
"removed_volume_mm3": removed,
|
||||||
|
}
|
||||||
|
return _pass(evidence) if removed > 1e-6 else _fail(evidence)
|
||||||
|
|
||||||
|
|
||||||
|
def _bbox(expected: dict[str, Any], facts: dict[str, Any]) -> ClaimResult:
|
||||||
|
health = facts.get("health") if isinstance(facts.get("health"), dict) else {}
|
||||||
|
dimensions = ((health.get("bbox_mm") or {}).get("dimensions") if isinstance(health.get("bbox_mm"), dict) else None)
|
||||||
|
axis = {"x": 0, "y": 1, "z": 2}[expected["axis"]]
|
||||||
|
if not isinstance(dimensions, list) or len(dimensions) != 3:
|
||||||
|
return _pending("rebuild report has no bounding-box dimensions")
|
||||||
|
actual = float(dimensions[axis])
|
||||||
|
tolerance = float(expected["tolerance_mm"])
|
||||||
|
return _pass({"axis": expected["axis"], "actual_mm": actual, "tolerance_mm": tolerance}) if abs(actual - expected["value"]) <= tolerance else _fail({"axis": expected["axis"], "expected_mm": expected["value"], "actual_mm": actual, "tolerance_mm": tolerance})
|
||||||
|
|
||||||
|
|
||||||
|
def _bbox_rank(expected: dict[str, Any], facts: dict[str, Any]) -> ClaimResult:
|
||||||
|
"""Measure a principal extent without assuming the author's world axes."""
|
||||||
|
health = facts.get("health") if isinstance(facts.get("health"), dict) else {}
|
||||||
|
dimensions = ((health.get("bbox_mm") or {}).get("dimensions") if isinstance(health.get("bbox_mm"), dict) else None)
|
||||||
|
if not isinstance(dimensions, list) or len(dimensions) != 3:
|
||||||
|
return _pending("rebuild report has no bounding-box dimensions")
|
||||||
|
try:
|
||||||
|
ranked = sorted(float(value) for value in dimensions)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return _pending("rebuild report has invalid bounding-box dimensions")
|
||||||
|
index = {"minimum": 0, "median": 1, "maximum": 2}[expected["rank"]]
|
||||||
|
actual = ranked[index]
|
||||||
|
tolerance = float(expected["tolerance_mm"])
|
||||||
|
return _pass({"rank": expected["rank"], "actual_mm": actual, "tolerance_mm": tolerance}) if abs(actual - expected["value"]) <= tolerance else _fail({"rank": expected["rank"], "expected_mm": expected["value"], "actual_mm": actual, "tolerance_mm": tolerance})
|
||||||
|
|
||||||
|
|
||||||
|
def _matching_cylinders(expected: dict[str, Any], facts: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
results: list[dict[str, Any]] = []
|
||||||
|
diameter = float(expected["diameter_mm"])
|
||||||
|
tolerance = float(expected.get("tolerance_mm", 0.01))
|
||||||
|
for record in _records(facts):
|
||||||
|
geometry = record.get("geometry") if isinstance(record.get("geometry"), dict) else {}
|
||||||
|
if geometry.get("surface_type") != "cylinder":
|
||||||
|
continue
|
||||||
|
# A cylinder bounded by two opposing planes can be either an external
|
||||||
|
# wall or an internal bore. Modern topology snapshots make this
|
||||||
|
# distinction explicit from B-rep face orientation. Do not accept an
|
||||||
|
# unknown/export-incomplete cylinder as a bore: that would reintroduce
|
||||||
|
# the false positive this verifier exists to prevent.
|
||||||
|
if geometry.get("cylinder_role") != "inner":
|
||||||
|
continue
|
||||||
|
radius = geometry.get("radius_mm")
|
||||||
|
if isinstance(radius, (int, float)) and abs(float(radius) * 2 - diameter) <= tolerance:
|
||||||
|
results.append(record)
|
||||||
|
by_id = {
|
||||||
|
str(record.get("record_id") or ""): record
|
||||||
|
for record in _records(facts)
|
||||||
|
if isinstance(record.get("record_id"), str)
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
_merged_inner_cylindrical_shell(group, by_id)
|
||||||
|
for group in _cylindrical_shells(results, tolerance)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _matching_outer_cylinders(expected: dict[str, Any], facts: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
results: list[dict[str, Any]] = []
|
||||||
|
diameter = float(expected["diameter_mm"])
|
||||||
|
tolerance = float(expected.get("tolerance_mm", 0.01))
|
||||||
|
for record in _records(facts):
|
||||||
|
geometry = record.get("geometry") if isinstance(record.get("geometry"), dict) else {}
|
||||||
|
if geometry.get("surface_type") != "cylinder" or geometry.get("cylinder_role") != "outer":
|
||||||
|
continue
|
||||||
|
radius = geometry.get("radius_mm")
|
||||||
|
if isinstance(radius, (int, float)) and abs(float(radius) * 2 - diameter) <= tolerance:
|
||||||
|
results.append(record)
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def _same_cylindrical_shell(left: dict[str, Any], right: dict[str, Any], tolerance: float) -> bool:
|
||||||
|
"""Identify B-rep patches that represent one continuous cylindrical shell.
|
||||||
|
|
||||||
|
OpenCascade can split an extruded circular wall into four periodic-face
|
||||||
|
patches. Those patches have the same axis/radius and axial interval, but
|
||||||
|
are not four physical outer diameters. Conversely, equal-diameter shaft
|
||||||
|
sections separated along their axis must remain distinct claim instances.
|
||||||
|
"""
|
||||||
|
left_axis = _cylinder_axis_direction(left)
|
||||||
|
right_axis = _cylinder_axis_direction(right)
|
||||||
|
left_origin = _cylinder_axis_point(left)
|
||||||
|
right_origin = _cylinder_axis_point(right)
|
||||||
|
left_interval = _cylinder_axis_interval(left)
|
||||||
|
right_interval = _cylinder_axis_interval(right)
|
||||||
|
if None in {left_axis, right_axis, left_origin, right_origin, left_interval, right_interval}:
|
||||||
|
return False
|
||||||
|
assert left_axis is not None and right_axis is not None
|
||||||
|
assert left_origin is not None and right_origin is not None
|
||||||
|
assert left_interval is not None and right_interval is not None
|
||||||
|
alignment = abs(sum(left_axis[index] * right_axis[index] for index in range(3)))
|
||||||
|
if alignment < 1.0 - 1e-6:
|
||||||
|
return False
|
||||||
|
delta = tuple(right_origin[index] - left_origin[index] for index in range(3))
|
||||||
|
axial = sum(delta[index] * left_axis[index] for index in range(3))
|
||||||
|
radial_offset = sqrt(sum((delta[index] - axial * left_axis[index]) ** 2 for index in range(3)))
|
||||||
|
if radial_offset > tolerance:
|
||||||
|
return False
|
||||||
|
return left_interval[0] <= right_interval[1] + tolerance and right_interval[0] <= left_interval[1] + tolerance
|
||||||
|
|
||||||
|
|
||||||
|
def _cylindrical_shells(records: list[dict[str, Any]], tolerance: float) -> list[list[dict[str, Any]]]:
|
||||||
|
"""Group periodic B-rep cylinder patches into physical cylindrical shells."""
|
||||||
|
groups: list[list[dict[str, Any]]] = []
|
||||||
|
for record in records:
|
||||||
|
overlapping = [index for index, group in enumerate(groups) if any(_same_cylindrical_shell(record, member, tolerance) for member in group)]
|
||||||
|
if not overlapping:
|
||||||
|
groups.append([record])
|
||||||
|
continue
|
||||||
|
first = overlapping[0]
|
||||||
|
groups[first].append(record)
|
||||||
|
for index in reversed(overlapping[1:]):
|
||||||
|
groups[first].extend(groups.pop(index))
|
||||||
|
return groups
|
||||||
|
|
||||||
|
|
||||||
|
def _merged_inner_cylindrical_shell(
|
||||||
|
members: list[dict[str, Any]],
|
||||||
|
records_by_id: dict[str, dict[str, Any]],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Build one verifier-facing bore record from periodic inner-face patches.
|
||||||
|
|
||||||
|
Extruding an analytic circle can produce several cylindrical B-rep faces.
|
||||||
|
Individual patches touch only one end plane, while the union represents a
|
||||||
|
through bore. Keep topology records raw for selectors, but aggregate the
|
||||||
|
measured surface for acceptance claims.
|
||||||
|
"""
|
||||||
|
representative = members[0]
|
||||||
|
geometry = representative.get("geometry") if isinstance(representative.get("geometry"), dict) else {}
|
||||||
|
merged_geometry = dict(geometry)
|
||||||
|
member_ids = [str(member.get("record_id") or "") for member in members if member.get("record_id")]
|
||||||
|
connected_plane_ids = list(dict.fromkeys(
|
||||||
|
str(plane_id)
|
||||||
|
for member in members
|
||||||
|
for plane_id in ((member.get("geometry") or {}).get("connected_plane_ids") or ())
|
||||||
|
if isinstance(plane_id, str)
|
||||||
|
))
|
||||||
|
merged_geometry["member_record_ids"] = member_ids
|
||||||
|
merged_geometry["connected_plane_ids"] = connected_plane_ids
|
||||||
|
axis = _cylinder_axis_direction(representative)
|
||||||
|
merged_geometry["through"] = bool(geometry.get("through")) or _planes_prove_through(
|
||||||
|
connected_plane_ids,
|
||||||
|
axis,
|
||||||
|
records_by_id,
|
||||||
|
)
|
||||||
|
return {**representative, "geometry": merged_geometry}
|
||||||
|
|
||||||
|
|
||||||
|
def _planes_prove_through(
|
||||||
|
plane_ids: list[str],
|
||||||
|
axis: tuple[float, float, float] | None,
|
||||||
|
records_by_id: dict[str, dict[str, Any]],
|
||||||
|
) -> bool:
|
||||||
|
if axis is None:
|
||||||
|
return False
|
||||||
|
planes = [
|
||||||
|
records_by_id[record_id].get("geometry")
|
||||||
|
for record_id in plane_ids
|
||||||
|
if isinstance(records_by_id.get(record_id), dict)
|
||||||
|
and isinstance(records_by_id[record_id].get("geometry"), dict)
|
||||||
|
and records_by_id[record_id]["geometry"].get("surface_type") == "plane"
|
||||||
|
]
|
||||||
|
for first_index, first in enumerate(planes):
|
||||||
|
if not isinstance(first, dict):
|
||||||
|
continue
|
||||||
|
first_normal = _vector(first.get("normal"))
|
||||||
|
if first_normal is None:
|
||||||
|
continue
|
||||||
|
for second in planes[first_index + 1:]:
|
||||||
|
if not isinstance(second, dict):
|
||||||
|
continue
|
||||||
|
second_normal = _vector(second.get("normal"))
|
||||||
|
if second_normal is None:
|
||||||
|
continue
|
||||||
|
opposite = sum(first_normal[index] * second_normal[index] for index in range(3)) <= -0.99
|
||||||
|
axial = all(abs(sum(normal[index] * axis[index] for index in range(3))) >= 0.99 for normal in (first_normal, second_normal))
|
||||||
|
if opposite and axial:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _outer_cylinder_shells(expected: dict[str, Any], facts: dict[str, Any]) -> list[list[dict[str, Any]]]:
|
||||||
|
"""Coalesce periodic B-rep patches into physical outer-cylinder shells."""
|
||||||
|
matched = _matching_outer_cylinders(expected, facts)
|
||||||
|
tolerance = float(expected.get("tolerance_mm", 0.01))
|
||||||
|
return _cylindrical_shells(matched, tolerance)
|
||||||
|
|
||||||
|
|
||||||
|
def _matching_cones(expected: dict[str, Any], facts: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
results: list[dict[str, Any]] = []
|
||||||
|
small = float(expected["small_diameter_mm"])
|
||||||
|
large = float(expected["large_diameter_mm"])
|
||||||
|
included_angle = float(expected["included_angle_deg"])
|
||||||
|
tolerance = float(expected.get("tolerance_mm", 0.01))
|
||||||
|
for record in _records(facts):
|
||||||
|
geometry = record.get("geometry") if isinstance(record.get("geometry"), dict) else {}
|
||||||
|
if geometry.get("surface_type") != "cone" or geometry.get("cylinder_role") != "inner":
|
||||||
|
continue
|
||||||
|
radii = geometry.get("boundary_radii_mm")
|
||||||
|
angle = geometry.get("semi_angle_deg")
|
||||||
|
if not isinstance(radii, list) or not isinstance(angle, (int, float)):
|
||||||
|
continue
|
||||||
|
values = [float(value) for value in radii if isinstance(value, (int, float))]
|
||||||
|
if not values:
|
||||||
|
continue
|
||||||
|
if (
|
||||||
|
abs(min(values) * 2 - small) <= tolerance
|
||||||
|
and abs(max(values) * 2 - large) <= tolerance
|
||||||
|
and abs(abs(float(angle)) * 2 - included_angle) <= 1e-4
|
||||||
|
):
|
||||||
|
results.append(record)
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def _cylindrical_bore(expected: dict[str, Any], facts: dict[str, Any]) -> ClaimResult:
|
||||||
|
matched = _matching_cylinders(expected, facts)
|
||||||
|
count = int(expected.get("count", 1))
|
||||||
|
if not _records(facts):
|
||||||
|
return _pending("topology is unavailable")
|
||||||
|
if not matched:
|
||||||
|
return _pending("the target bore has not been introduced at this checkpoint")
|
||||||
|
if len(matched) < count:
|
||||||
|
return _pending("the target bore pattern is incomplete at this checkpoint")
|
||||||
|
return _pass({"matched_cylindrical_faces": [item.get("record_id") for item in matched]}) if len(matched) == count else _fail({"expected_count": count, "actual_count": len(matched), "diameter_mm": expected["diameter_mm"]})
|
||||||
|
|
||||||
|
|
||||||
|
def _through_cylindrical_bore(expected: dict[str, Any], facts: dict[str, Any]) -> ClaimResult:
|
||||||
|
matched = _matching_cylinders(expected, facts)
|
||||||
|
if not _records(facts):
|
||||||
|
return _pending("topology is unavailable")
|
||||||
|
if not matched:
|
||||||
|
return _pending("the target bore has not been introduced at this checkpoint")
|
||||||
|
# A bore is through only when the B-rep exporter established that its
|
||||||
|
# cylindrical face links two oppositely oriented plane faces. The flag is
|
||||||
|
# derived from shared topology edges, not from its requested depth.
|
||||||
|
through = [item for item in matched if bool((item.get("geometry") or {}).get("through"))]
|
||||||
|
count = int(expected.get("count", 1))
|
||||||
|
if len(matched) < count and len(through) == len(matched):
|
||||||
|
return _pending("the target through-bore pattern is incomplete at this checkpoint")
|
||||||
|
return _pass({"through_bores": [item.get("record_id") for item in through]}) if len(matched) == count and len(through) == count else _fail({"expected_count": count, "actual_count": len(matched), "actual_through_count": len(through), "diameter_mm": expected["diameter_mm"]})
|
||||||
|
|
||||||
|
|
||||||
|
def _cylindrical_bore_depth(expected: dict[str, Any], facts: dict[str, Any]) -> ClaimResult:
|
||||||
|
matched = _matching_cylinders(expected, facts)
|
||||||
|
count = int(expected.get("count", 1))
|
||||||
|
if not _records(facts):
|
||||||
|
return _pending("topology is unavailable")
|
||||||
|
if not matched:
|
||||||
|
return _pending("the target bore has not been introduced at this checkpoint")
|
||||||
|
if len(matched) < count:
|
||||||
|
return _pending("the target bore pattern is incomplete at this checkpoint")
|
||||||
|
if len(matched) != count:
|
||||||
|
return _fail({"expected_count": count, "actual_count": len(matched), "diameter_mm": expected["diameter_mm"]})
|
||||||
|
spans = [_cylinder_axis_span(record) for record in matched]
|
||||||
|
if any(span is None for span in spans):
|
||||||
|
return _pending("target bore faces have no measurable axial span")
|
||||||
|
depth = float(expected["depth_mm"])
|
||||||
|
tolerance = float(expected["tolerance_mm"])
|
||||||
|
actual = [float(span) for span in spans if span is not None]
|
||||||
|
return _pass({"diameter_mm": expected["diameter_mm"], "depths_mm": actual, "tolerance_mm": tolerance}) if all(abs(value - depth) <= tolerance for value in actual) else _fail({"diameter_mm": expected["diameter_mm"], "expected_depth_mm": depth, "actual_depths_mm": actual, "tolerance_mm": tolerance})
|
||||||
|
|
||||||
|
|
||||||
|
def _conical_bore(expected: dict[str, Any], facts: dict[str, Any]) -> ClaimResult:
|
||||||
|
matched = _matching_cones(expected, facts)
|
||||||
|
count = int(expected.get("count", 1))
|
||||||
|
if not _records(facts):
|
||||||
|
return _pending("topology is unavailable")
|
||||||
|
if not matched:
|
||||||
|
return _pending("the target conical bore has not been introduced at this checkpoint")
|
||||||
|
if len(matched) < count:
|
||||||
|
return _pending("the target conical-bore pattern is incomplete at this checkpoint")
|
||||||
|
return _pass({"conical_bores": [item.get("record_id") for item in matched], "small_diameter_mm": expected["small_diameter_mm"], "large_diameter_mm": expected["large_diameter_mm"], "included_angle_deg": expected["included_angle_deg"]}) if len(matched) == count else _fail({"expected_count": count, "actual_count": len(matched), "small_diameter_mm": expected["small_diameter_mm"], "large_diameter_mm": expected["large_diameter_mm"], "included_angle_deg": expected["included_angle_deg"]})
|
||||||
|
|
||||||
|
|
||||||
|
def _outer_cylindrical_surface(expected: dict[str, Any], facts: dict[str, Any]) -> ClaimResult:
|
||||||
|
shells = _outer_cylinder_shells(expected, facts)
|
||||||
|
count = int(expected.get("count", 1))
|
||||||
|
if not _records(facts):
|
||||||
|
return _pending("topology is unavailable")
|
||||||
|
if not shells:
|
||||||
|
return _pending("the target external cylindrical surface has not been introduced at this checkpoint")
|
||||||
|
if len(shells) < count:
|
||||||
|
return _pending("the target external cylindrical-surface pattern is incomplete at this checkpoint")
|
||||||
|
if len(shells) != count:
|
||||||
|
return _fail({"expected_count": count, "actual_count": len(shells), "diameter_mm": expected["diameter_mm"]})
|
||||||
|
axial_span = expected.get("axial_span_mm")
|
||||||
|
evidence: dict[str, Any] = {
|
||||||
|
"external_cylindrical_faces": [item.get("record_id") for shell in shells for item in shell],
|
||||||
|
"external_cylindrical_surface_groups": [[item.get("record_id") for item in shell] for shell in shells],
|
||||||
|
"diameter_mm": expected["diameter_mm"],
|
||||||
|
}
|
||||||
|
if axial_span is None:
|
||||||
|
return _pass(evidence)
|
||||||
|
intervals = [_cylinder_axis_interval(shell[0]) for shell in shells]
|
||||||
|
if any(interval is None for interval in intervals):
|
||||||
|
return _pending("target external cylindrical faces have no measurable axial span")
|
||||||
|
actual_spans = []
|
||||||
|
for shell in shells:
|
||||||
|
shell_intervals = [_cylinder_axis_interval(record) for record in shell]
|
||||||
|
if any(interval is None for interval in shell_intervals):
|
||||||
|
return _pending("target external cylindrical faces have no measurable axial span")
|
||||||
|
actual_spans.append(
|
||||||
|
max(interval[1] for interval in shell_intervals if interval is not None)
|
||||||
|
- min(interval[0] for interval in shell_intervals if interval is not None)
|
||||||
|
)
|
||||||
|
tolerance = float(expected["tolerance_mm"])
|
||||||
|
evidence.update({"axial_spans_mm": actual_spans, "tolerance_mm": tolerance})
|
||||||
|
return _pass(evidence) if all(abs(span - float(axial_span)) <= tolerance for span in actual_spans) else _fail({"diameter_mm": expected["diameter_mm"], "expected_axial_span_mm": axial_span, "actual_axial_spans_mm": actual_spans, "tolerance_mm": tolerance})
|
||||||
|
|
||||||
|
|
||||||
|
def _hole_pattern(expected: dict[str, Any], facts: dict[str, Any]) -> ClaimResult:
|
||||||
|
# The same topology evidence supports the count/diameter component; the
|
||||||
|
# runtime can add per-cylinder centres to prove PCD and angular spacing.
|
||||||
|
matched = _matching_cylinders(expected, facts)
|
||||||
|
count = int(expected["count"])
|
||||||
|
if not _records(facts):
|
||||||
|
return _pending("topology is unavailable")
|
||||||
|
if not matched:
|
||||||
|
return _pending("the target hole pattern has not been introduced at this checkpoint")
|
||||||
|
centers = [_cylinder_axis_point(item) for item in matched]
|
||||||
|
centers = [center for center in centers if center is not None]
|
||||||
|
if len(matched) < count:
|
||||||
|
return _pending("the target hole pattern is incomplete at this checkpoint")
|
||||||
|
if len(matched) != count or len(centers) != count:
|
||||||
|
return _fail({"expected_count": count, "actual_count": len(matched), "centred_count": len(centers), "diameter_mm": expected["diameter_mm"]})
|
||||||
|
radius = float(expected["pitch_radius_mm"])
|
||||||
|
tolerance = float(expected["tolerance_mm"])
|
||||||
|
selected = centers
|
||||||
|
centroid = tuple(sum(point[index] for point in selected) / count for index in range(3))
|
||||||
|
radial = [sqrt(sum((point[index] - centroid[index]) ** 2 for index in range(3))) for point in selected]
|
||||||
|
if not all(abs(value - radius) <= tolerance for value in radial):
|
||||||
|
return _fail({"expected_pitch_radius_mm": radius, "measured_radii_mm": radial, "tolerance_mm": tolerance})
|
||||||
|
central_bore_diameter = expected.get("concentric_bore_diameter_mm")
|
||||||
|
if central_bore_diameter is not None:
|
||||||
|
central = _matching_cylinders({"diameter_mm": central_bore_diameter, "tolerance_mm": tolerance}, facts)
|
||||||
|
central_axes = [_cylinder_axis_point(record) for record in central if bool((record.get("geometry") or {}).get("through"))]
|
||||||
|
central_axes = [axis for axis in central_axes if axis is not None]
|
||||||
|
if not central_axes:
|
||||||
|
return _pending("the required concentric reference bore is not available")
|
||||||
|
if len(central_axes) != 1:
|
||||||
|
return _fail({"reason": "concentric reference bore is ambiguous", "diameter_mm": central_bore_diameter, "count": len(central_axes)})
|
||||||
|
axis = central_axes[0]
|
||||||
|
normal = _cylinder_axis_direction(central[0])
|
||||||
|
if normal is None:
|
||||||
|
return _pending("the required concentric reference bore has no measurable axis")
|
||||||
|
delta = tuple(centroid[index] - axis[index] for index in range(3))
|
||||||
|
axial = sum(delta[index] * normal[index] for index in range(3))
|
||||||
|
concentric_error = sqrt(sum((delta[index] - axial * normal[index]) ** 2 for index in range(3)))
|
||||||
|
if concentric_error > tolerance:
|
||||||
|
return _fail({"expected_concentric_bore_diameter_mm": central_bore_diameter, "pattern_centroid_mm": centroid, "reference_axis_point_mm": axis, "concentric_error_mm": concentric_error, "tolerance_mm": tolerance})
|
||||||
|
angular = _equal_angular_spacing(selected, centroid, radius, tolerance)
|
||||||
|
if angular is None:
|
||||||
|
return _fail({"reason": "hole centres are degenerate or not evenly spaced", "centres_mm": selected, "tolerance_mm": tolerance})
|
||||||
|
evidence = {"count": count, "pitch_radius_mm": radius, "centres_mm": selected, "angular_spacing_rad": angular}
|
||||||
|
if central_bore_diameter is not None:
|
||||||
|
evidence["concentric_bore_diameter_mm"] = central_bore_diameter
|
||||||
|
return _pass(evidence)
|
||||||
|
|
||||||
|
|
||||||
|
def _equal_angular_spacing(
|
||||||
|
centres: list[tuple[float, float, float]],
|
||||||
|
centroid: tuple[float, float, float],
|
||||||
|
radius: float,
|
||||||
|
tolerance: float,
|
||||||
|
) -> list[float] | None:
|
||||||
|
"""Return cyclic angular increments only for a genuinely even circle."""
|
||||||
|
vectors = [tuple(point[index] - centroid[index] for index in range(3)) for point in centres]
|
||||||
|
base_length = sqrt(sum(value * value for value in vectors[0]))
|
||||||
|
if base_length == 0:
|
||||||
|
return None
|
||||||
|
normal: tuple[float, float, float] | None = None
|
||||||
|
for vector in vectors[1:]:
|
||||||
|
cross = (
|
||||||
|
vectors[0][1] * vector[2] - vectors[0][2] * vector[1],
|
||||||
|
vectors[0][2] * vector[0] - vectors[0][0] * vector[2],
|
||||||
|
vectors[0][0] * vector[1] - vectors[0][1] * vector[0],
|
||||||
|
)
|
||||||
|
length = sqrt(sum(value * value for value in cross))
|
||||||
|
if length > 1e-9:
|
||||||
|
normal = tuple(value / length for value in cross)
|
||||||
|
break
|
||||||
|
if normal is None:
|
||||||
|
# Two diametrically opposed holes are evenly spaced exactly when both
|
||||||
|
# are at the requested radius around their midpoint.
|
||||||
|
return [pi, pi] if len(centres) == 2 else None
|
||||||
|
basis_x = tuple(value / base_length for value in vectors[0])
|
||||||
|
basis_y = (
|
||||||
|
normal[1] * basis_x[2] - normal[2] * basis_x[1],
|
||||||
|
normal[2] * basis_x[0] - normal[0] * basis_x[2],
|
||||||
|
normal[0] * basis_x[1] - normal[1] * basis_x[0],
|
||||||
|
)
|
||||||
|
angles = sorted(
|
||||||
|
atan2(
|
||||||
|
sum(vector[index] * basis_y[index] for index in range(3)),
|
||||||
|
sum(vector[index] * basis_x[index] for index in range(3)),
|
||||||
|
)
|
||||||
|
for vector in vectors
|
||||||
|
)
|
||||||
|
increments = [
|
||||||
|
(angles[(index + 1) % len(angles)] - angles[index]) % (2 * pi)
|
||||||
|
for index in range(len(angles))
|
||||||
|
]
|
||||||
|
target = 2 * pi / len(centres)
|
||||||
|
return increments if all(abs(increment - target) * radius <= tolerance for increment in increments) else None
|
||||||
|
|
||||||
|
|
||||||
|
def _collinear_bore_chain(expected: dict[str, Any], facts: dict[str, Any]) -> ClaimResult:
|
||||||
|
"""Prove a coplanar bore-centre chain in either direction.
|
||||||
|
|
||||||
|
``axis_origin_mm`` may be either end of a through bore, depending on the
|
||||||
|
host-face orientation used to create that feature. Project each axis line
|
||||||
|
onto the common plane normal to the bore direction before comparing its
|
||||||
|
centre location; otherwise equivalent top- and bottom-hosted holes appear
|
||||||
|
to be offset by the part thickness.
|
||||||
|
"""
|
||||||
|
matched = [
|
||||||
|
record for record in _matching_cylinders(expected, facts)
|
||||||
|
if bool((record.get("geometry") or {}).get("through"))
|
||||||
|
]
|
||||||
|
expected_distances = [float(value) for value in expected["adjacent_distances_mm"]]
|
||||||
|
count = len(expected_distances) + 1
|
||||||
|
tolerance = float(expected["tolerance_mm"])
|
||||||
|
centres = [_cylinder_axis_point(record) for record in matched]
|
||||||
|
centres = [centre for centre in centres if centre is not None]
|
||||||
|
if not _records(facts):
|
||||||
|
return _pending("topology is unavailable")
|
||||||
|
if not matched:
|
||||||
|
return _pending("the target bore chain has not been introduced at this checkpoint")
|
||||||
|
# A checkpoint may deliberately add this chain across more than one
|
||||||
|
# action. Fewer already-through matching bores are therefore unfinished
|
||||||
|
# future work, while a matching bore that is not through is a real
|
||||||
|
# violation of this claim.
|
||||||
|
if len(centres) < count and len(centres) == len(matched):
|
||||||
|
return _pending("the target bore chain is incomplete at this checkpoint")
|
||||||
|
if len(centres) != count:
|
||||||
|
return _fail({"expected_count": count, "actual_count": len(centres), "diameter_mm": expected["diameter_mm"]})
|
||||||
|
directions = [_cylinder_axis_direction(record) for record in matched]
|
||||||
|
if all(direction is not None for direction in directions):
|
||||||
|
reference_direction = directions[0]
|
||||||
|
assert reference_direction is not None
|
||||||
|
alignments = [
|
||||||
|
abs(sum(reference_direction[index] * direction[index] for index in range(3)))
|
||||||
|
for direction in directions
|
||||||
|
if direction is not None
|
||||||
|
]
|
||||||
|
if any(alignment < 1.0 - 1e-6 for alignment in alignments):
|
||||||
|
return _fail({"reason": "bore axes are not parallel", "axis_alignments": alignments})
|
||||||
|
# This is the unique point where each parallel bore axis intersects
|
||||||
|
# the plane through the world origin normal to ``reference_direction``.
|
||||||
|
# It is invariant to choosing either axial endpoint as axis_origin_mm.
|
||||||
|
centres = [
|
||||||
|
tuple(
|
||||||
|
centre[index]
|
||||||
|
- sum(centre[component] * reference_direction[component] for component in range(3))
|
||||||
|
* reference_direction[index]
|
||||||
|
for index in range(3)
|
||||||
|
)
|
||||||
|
for centre in centres
|
||||||
|
]
|
||||||
|
origin = centres[0]
|
||||||
|
endpoint = max(centres[1:], key=lambda centre: sum((centre[index] - origin[index]) ** 2 for index in range(3)))
|
||||||
|
direction = tuple(endpoint[index] - origin[index] for index in range(3))
|
||||||
|
direction_length = sqrt(sum(value * value for value in direction))
|
||||||
|
if direction_length <= tolerance:
|
||||||
|
return _fail({"reason": "bore centres do not define a non-zero chain axis"})
|
||||||
|
unit = tuple(value / direction_length for value in direction)
|
||||||
|
projections: list[tuple[float, tuple[float, float, float]]] = []
|
||||||
|
for centre in centres:
|
||||||
|
relative = tuple(centre[index] - origin[index] for index in range(3))
|
||||||
|
projection = sum(relative[index] * unit[index] for index in range(3))
|
||||||
|
residual = sqrt(sum((relative[index] - projection * unit[index]) ** 2 for index in range(3)))
|
||||||
|
if residual > tolerance:
|
||||||
|
return _fail({"reason": "bore centres are not collinear", "residual_mm": residual, "tolerance_mm": tolerance})
|
||||||
|
projections.append((projection, centre))
|
||||||
|
projections.sort(key=lambda item: item[0])
|
||||||
|
actual_distances = [projections[index + 1][0] - projections[index][0] for index in range(count - 1)]
|
||||||
|
matches_forward = all(
|
||||||
|
abs(actual - target) <= tolerance
|
||||||
|
for actual, target in zip(actual_distances, expected_distances, strict=True)
|
||||||
|
)
|
||||||
|
matches_reverse = all(
|
||||||
|
abs(actual - target) <= tolerance
|
||||||
|
for actual, target in zip(actual_distances, reversed(expected_distances), strict=True)
|
||||||
|
)
|
||||||
|
if not matches_forward and not matches_reverse:
|
||||||
|
return _fail({"expected_adjacent_distances_mm": expected_distances, "actual_adjacent_distances_mm": actual_distances, "tolerance_mm": tolerance})
|
||||||
|
return _pass({
|
||||||
|
"centres_mm": [centre for _projection, centre in projections],
|
||||||
|
"adjacent_distances_mm": actual_distances,
|
||||||
|
"expected_orientation": "forward" if matches_forward else "reversed",
|
||||||
|
"tolerance_mm": tolerance,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _coaxial_through_bore_group(expected: dict[str, Any], facts: dict[str, Any]) -> ClaimResult:
|
||||||
|
matched = _matching_cylinders(expected, facts)
|
||||||
|
count = int(expected["count"])
|
||||||
|
if not _records(facts):
|
||||||
|
return _pending("topology is unavailable")
|
||||||
|
if not matched:
|
||||||
|
return _pending("the target bore has not been introduced at this checkpoint")
|
||||||
|
through = [record for record in matched if bool((record.get("geometry") or {}).get("through"))]
|
||||||
|
if len(matched) < count and len(through) == len(matched):
|
||||||
|
return _pending("the target coaxial bore group is incomplete at this checkpoint")
|
||||||
|
if len(matched) != count or len(through) != count:
|
||||||
|
return _fail({"expected_count": count, "actual_count": len(matched), "actual_through_count": len(through), "diameter_mm": expected["diameter_mm"]})
|
||||||
|
points = [_cylinder_axis_point(record) for record in through]
|
||||||
|
directions = [_cylinder_axis_direction(record) for record in through]
|
||||||
|
if any(point is None for point in points) or any(direction is None for direction in directions):
|
||||||
|
return _pending("target bore axes are unavailable")
|
||||||
|
point_values = [point for point in points if point is not None]
|
||||||
|
direction_values = [direction for direction in directions if direction is not None]
|
||||||
|
tolerance = float(expected["tolerance_mm"])
|
||||||
|
reference_point, reference_direction = point_values[0], direction_values[0]
|
||||||
|
deviations: list[dict[str, Any]] = []
|
||||||
|
for record, point, direction in zip(through[1:], point_values[1:], direction_values[1:], strict=True):
|
||||||
|
dot = abs(sum(reference_direction[index] * direction[index] for index in range(3)))
|
||||||
|
offset = tuple(point[index] - reference_point[index] for index in range(3))
|
||||||
|
distance = sqrt(sum((offset[index] - sum(offset[component] * reference_direction[component] for component in range(3)) * reference_direction[index]) ** 2 for index in range(3)))
|
||||||
|
deviations.append({"record_id": record.get("record_id"), "axis_dot": dot, "axis_distance_mm": distance})
|
||||||
|
return _pass({"diameter_mm": expected["diameter_mm"], "axes": deviations, "tolerance_mm": tolerance}) if all(item["axis_dot"] >= 1 - 1e-6 and item["axis_distance_mm"] <= tolerance for item in deviations) else _fail({"diameter_mm": expected["diameter_mm"], "axes": deviations, "tolerance_mm": tolerance})
|
||||||
|
|
||||||
|
|
||||||
|
def _orthogonal_intersecting_through_bores(expected: dict[str, Any], facts: dict[str, Any]) -> ClaimResult:
|
||||||
|
first = _matching_cylinders({"diameter_mm": expected["first_diameter_mm"], "tolerance_mm": expected["tolerance_mm"]}, facts)
|
||||||
|
second = _matching_cylinders({"diameter_mm": expected["second_diameter_mm"], "tolerance_mm": expected["tolerance_mm"]}, facts)
|
||||||
|
if not _records(facts):
|
||||||
|
return _pending("topology is unavailable")
|
||||||
|
if not first or not second:
|
||||||
|
return _pending("one or both target bores have not been introduced at this checkpoint")
|
||||||
|
first_through = [record for record in first if bool((record.get("geometry") or {}).get("through"))]
|
||||||
|
second_through = [record for record in second if bool((record.get("geometry") or {}).get("through"))]
|
||||||
|
if len(first) != 1 or len(second) != 1 or len(first_through) != 1 or len(second_through) != 1:
|
||||||
|
return _fail({"first_diameter_mm": expected["first_diameter_mm"], "first_count": len(first), "first_through_count": len(first_through), "second_diameter_mm": expected["second_diameter_mm"], "second_count": len(second), "second_through_count": len(second_through)})
|
||||||
|
first_point, second_point = _cylinder_axis_point(first_through[0]), _cylinder_axis_point(second_through[0])
|
||||||
|
first_axis, second_axis = _cylinder_axis_direction(first_through[0]), _cylinder_axis_direction(second_through[0])
|
||||||
|
if first_point is None or second_point is None or first_axis is None or second_axis is None:
|
||||||
|
return _pending("target bore axes are unavailable")
|
||||||
|
expected_first_axis = {"x": (1.0, 0.0, 0.0), "y": (0.0, 1.0, 0.0), "z": (0.0, 0.0, 1.0)}[expected["first_axis"]]
|
||||||
|
expected_second_axis = {"x": (1.0, 0.0, 0.0), "y": (0.0, 1.0, 0.0), "z": (0.0, 0.0, 1.0)}[expected["second_axis"]]
|
||||||
|
first_axis_alignment = abs(sum(first_axis[index] * expected_first_axis[index] for index in range(3)))
|
||||||
|
second_axis_alignment = abs(sum(second_axis[index] * expected_second_axis[index] for index in range(3)))
|
||||||
|
dot = sum(first_axis[index] * second_axis[index] for index in range(3))
|
||||||
|
denominator = 1 - dot * dot
|
||||||
|
if denominator <= 1e-9:
|
||||||
|
return _fail({"reason": "bore axes are parallel", "axis_dot": dot})
|
||||||
|
offset = tuple(first_point[index] - second_point[index] for index in range(3))
|
||||||
|
first_parameter = (dot * sum(second_axis[index] * offset[index] for index in range(3)) - sum(first_axis[index] * offset[index] for index in range(3))) / denominator
|
||||||
|
second_parameter = (sum(second_axis[index] * offset[index] for index in range(3)) - dot * sum(first_axis[index] * offset[index] for index in range(3))) / denominator
|
||||||
|
closest_first = tuple(first_point[index] + first_parameter * first_axis[index] for index in range(3))
|
||||||
|
closest_second = tuple(second_point[index] + second_parameter * second_axis[index] for index in range(3))
|
||||||
|
separation = sqrt(sum((closest_first[index] - closest_second[index]) ** 2 for index in range(3)))
|
||||||
|
tolerance = float(expected["tolerance_mm"])
|
||||||
|
evidence = {
|
||||||
|
"axis_dot": dot,
|
||||||
|
"axis_intersection_separation_mm": separation,
|
||||||
|
"first_axis": expected["first_axis"],
|
||||||
|
"first_axis_alignment": first_axis_alignment,
|
||||||
|
"second_axis": expected["second_axis"],
|
||||||
|
"second_axis_alignment": second_axis_alignment,
|
||||||
|
"tolerance_mm": tolerance,
|
||||||
|
}
|
||||||
|
return _pass(evidence) if abs(dot) <= 1e-6 and separation <= tolerance and first_axis_alignment >= 1 - 1e-6 and second_axis_alignment >= 1 - 1e-6 else _fail(evidence)
|
||||||
|
|
||||||
|
|
||||||
|
def _rectangular_corner_bore_pattern(expected: dict[str, Any], facts: dict[str, Any]) -> ClaimResult:
|
||||||
|
"""Prove four through bores lie at one equal offset from a rectangular plate's edges."""
|
||||||
|
matched = _matching_cylinders(expected, facts)
|
||||||
|
count = int(expected["count"])
|
||||||
|
if not _records(facts):
|
||||||
|
return _pending("topology is unavailable")
|
||||||
|
if not matched:
|
||||||
|
return _pending("the target bore has not been introduced at this checkpoint")
|
||||||
|
through = [record for record in matched if bool((record.get("geometry") or {}).get("through"))]
|
||||||
|
if len(matched) < count and len(through) == len(matched):
|
||||||
|
return _pending("the target corner-bore pattern is incomplete at this checkpoint")
|
||||||
|
if len(matched) != count or len(through) != count:
|
||||||
|
return _fail({"expected_count": count, "actual_count": len(matched), "actual_through_count": len(through), "diameter_mm": expected["diameter_mm"]})
|
||||||
|
health = facts.get("health") if isinstance(facts.get("health"), dict) else {}
|
||||||
|
bbox = health.get("bbox_mm") if isinstance(health.get("bbox_mm"), dict) else {}
|
||||||
|
minimum, maximum = bbox.get("min"), bbox.get("max")
|
||||||
|
if not isinstance(minimum, list) or not isinstance(maximum, list) or len(minimum) != 3 or len(maximum) != 3:
|
||||||
|
return _pending("rebuild report has no bounding-box extrema")
|
||||||
|
normal = _cylinder_axis_direction(through[0])
|
||||||
|
centres = [_cylinder_axis_point(record) for record in through]
|
||||||
|
if normal is None or any(centre is None for centre in centres):
|
||||||
|
return _pending("target bore axes are unavailable")
|
||||||
|
normal_axis = max(range(3), key=lambda index: abs(normal[index]))
|
||||||
|
if abs(normal[normal_axis]) < 0.99:
|
||||||
|
return _pending("corner-bore pattern is not aligned to a measurable cardinal plate normal")
|
||||||
|
plane_axes = [index for index in range(3) if index != normal_axis]
|
||||||
|
try:
|
||||||
|
lower = [float(value) for value in minimum]
|
||||||
|
upper = [float(value) for value in maximum]
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return _pending("rebuild report has invalid bounding-box extrema")
|
||||||
|
offset = float(expected["edge_offset_mm"])
|
||||||
|
tolerance = float(expected["tolerance_mm"])
|
||||||
|
centre_values = [centre for centre in centres if centre is not None]
|
||||||
|
positions: list[tuple[int, int]] = []
|
||||||
|
for centre in centre_values:
|
||||||
|
position: list[int] = []
|
||||||
|
for axis in plane_axes:
|
||||||
|
low_error = abs((centre[axis] - lower[axis]) - offset)
|
||||||
|
high_error = abs((upper[axis] - centre[axis]) - offset)
|
||||||
|
if low_error <= tolerance:
|
||||||
|
position.append(0)
|
||||||
|
elif high_error <= tolerance:
|
||||||
|
position.append(1)
|
||||||
|
else:
|
||||||
|
return _fail({"reason": "bore centre has the wrong edge offset", "centre_mm": centre, "axis": ["x", "y", "z"][axis], "low_offset_mm": centre[axis] - lower[axis], "high_offset_mm": upper[axis] - centre[axis], "expected_edge_offset_mm": offset, "tolerance_mm": tolerance})
|
||||||
|
positions.append((position[0], position[1]))
|
||||||
|
expected_positions = {(0, 0), (0, 1), (1, 0), (1, 1)}
|
||||||
|
return _pass({"diameter_mm": expected["diameter_mm"], "edge_offset_mm": offset, "normal_axis": ["x", "y", "z"][normal_axis], "corner_positions": positions, "tolerance_mm": tolerance}) if set(positions) == expected_positions and len(set(positions)) == count else _fail({"reason": "bores do not occupy all rectangular corners", "corner_positions": positions, "expected_positions": sorted(expected_positions)})
|
||||||
|
|
||||||
|
|
||||||
|
def _coaxial(expected: dict[str, Any], facts: dict[str, Any]) -> ClaimResult:
|
||||||
|
ids = set(expected["record_ids"])
|
||||||
|
matching = [record for record in _records(facts) if str(record.get("record_id") or "") in ids]
|
||||||
|
if len(matching) != 2:
|
||||||
|
return _pending("referenced topology records are not both available")
|
||||||
|
axes = [_vector((record.get("geometry") or {}).get("axis_direction")) for record in matching]
|
||||||
|
if not all(axes):
|
||||||
|
return _pending("referenced records have no measurable axes")
|
||||||
|
a, b = axes # type: ignore[misc]
|
||||||
|
dot = abs(sum(a[index] * b[index] for index in range(3)))
|
||||||
|
return _pass({"axis_dot": dot}) if isclose(dot, 1.0, abs_tol=float(expected["tolerance"])) else _fail({"axis_dot": dot, "tolerance": expected["tolerance"]})
|
||||||
|
|
||||||
|
|
||||||
|
def _coplanar(expected: dict[str, Any], facts: dict[str, Any]) -> ClaimResult:
|
||||||
|
ids = set(expected["record_ids"])
|
||||||
|
matching = [record for record in _records(facts) if str(record.get("record_id") or "") in ids]
|
||||||
|
if len(matching) != 2:
|
||||||
|
return _pending("referenced topology records are not both available")
|
||||||
|
geometry = [(record.get("geometry") or {}) for record in matching]
|
||||||
|
normals = [_vector(value.get("plane_normal") or value.get("normal")) for value in geometry]
|
||||||
|
offsets = [value.get("plane_offset_mm") for value in geometry]
|
||||||
|
if not all(normals) or not all(isinstance(value, (int, float)) for value in offsets):
|
||||||
|
return _pending("referenced records have no measurable planes")
|
||||||
|
dot = abs(sum(normals[0][index] * normals[1][index] for index in range(3))) # type: ignore[index]
|
||||||
|
offset = abs(float(offsets[0]) - float(offsets[1]))
|
||||||
|
tolerance = float(expected["tolerance_mm"])
|
||||||
|
return _pass({"normal_dot": dot, "offset_delta_mm": offset}) if isclose(dot, 1.0, abs_tol=1e-5) and offset <= tolerance else _fail({"normal_dot": dot, "offset_delta_mm": offset, "tolerance_mm": tolerance})
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ClaimDefinition:
|
||||||
|
claim_kind: str
|
||||||
|
expected_schema: dict[str, Any]
|
||||||
|
required_artifacts: tuple[str, ...]
|
||||||
|
evaluator: ClaimEvaluator
|
||||||
|
deterministic: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class VerifierRegistry:
|
||||||
|
def __init__(self, definitions: tuple[ClaimDefinition, ...]) -> None:
|
||||||
|
self._definitions = {item.claim_kind: item for item in definitions}
|
||||||
|
if len(self._definitions) != len(definitions):
|
||||||
|
raise ValueError("Verifier claim_kind values must be unique")
|
||||||
|
for definition in definitions:
|
||||||
|
if not _is_closed_schema(definition.expected_schema):
|
||||||
|
raise ValueError(f"Verifier schema is not closed: {definition.claim_kind}")
|
||||||
|
try:
|
||||||
|
Draft202012Validator.check_schema(definition.expected_schema)
|
||||||
|
except SchemaError as error:
|
||||||
|
raise ValueError(f"Invalid verifier schema: {definition.claim_kind}") from error
|
||||||
|
|
||||||
|
@property
|
||||||
|
def claim_kinds(self) -> tuple[str, ...]:
|
||||||
|
return tuple(sorted(self._definitions))
|
||||||
|
|
||||||
|
def definition(self, claim_kind: str) -> ClaimDefinition:
|
||||||
|
try:
|
||||||
|
return self._definitions[claim_kind]
|
||||||
|
except KeyError as error:
|
||||||
|
raise ValueError(f"VERIFIER_UNAVAILABLE: {claim_kind}") from error
|
||||||
|
|
||||||
|
def expected_one_of_schema(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"oneOf": [
|
||||||
|
_closed_object({"claim_kind": {"const": item.claim_kind}, "expected": item.expected_schema}, ["claim_kind", "expected"])
|
||||||
|
for item in self._definitions.values()
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
def validate_expected(self, claim_kind: str, expected: dict[str, Any]) -> list[dict[str, str]]:
|
||||||
|
definition = self.definition(claim_kind)
|
||||||
|
validator = Draft202012Validator(definition.expected_schema)
|
||||||
|
return [
|
||||||
|
{"path": "/" + "/".join(str(part) for part in error.absolute_path), "message": error.message}
|
||||||
|
for error in sorted(validator.iter_errors(expected), key=lambda item: (list(item.absolute_path), item.message))
|
||||||
|
]
|
||||||
|
|
||||||
|
def evaluate(self, claim_kind: str, expected: dict[str, Any], facts: dict[str, Any]) -> ClaimResult:
|
||||||
|
errors = self.validate_expected(claim_kind, expected)
|
||||||
|
if errors:
|
||||||
|
return {"status": "unavailable", "evidence": {"schema_errors": errors}}
|
||||||
|
return self.definition(claim_kind).evaluator(expected, facts)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_closed_schema(schema: Any) -> bool:
|
||||||
|
if not isinstance(schema, dict):
|
||||||
|
return False
|
||||||
|
if schema.get("type") == "object" and schema.get("additionalProperties") is not False:
|
||||||
|
return False
|
||||||
|
for key in ("properties", "$defs", "definitions"):
|
||||||
|
values = schema.get(key)
|
||||||
|
if isinstance(values, dict) and not all(_is_closed_schema(value) for value in values.values()):
|
||||||
|
return False
|
||||||
|
for key in ("items", "additionalItems"):
|
||||||
|
if key in schema and isinstance(schema[key], dict) and not _is_closed_schema(schema[key]):
|
||||||
|
return False
|
||||||
|
for key in ("oneOf", "anyOf", "allOf"):
|
||||||
|
if key in schema and (not isinstance(schema[key], list) or not all(_is_closed_schema(value) for value in schema[key])):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def default_registry() -> VerifierRegistry:
|
||||||
|
# Acceptance tolerances are author-selected but never author-unbounded.
|
||||||
|
# A broad value could otherwise turn an exact dimensions claim into a
|
||||||
|
# vacuous pass. A tenth of a millimetre is a deliberately generous upper bound for
|
||||||
|
# this CAD protocol; requests needing looser acceptance must be made
|
||||||
|
# explicit through a separate verifier rather than weakening all claims.
|
||||||
|
positive = {"type": "number", "exclusiveMinimum": 0, "maximum": 100_000}
|
||||||
|
tolerance = {"type": "number", "minimum": 0, "maximum": 0.1}
|
||||||
|
alignment_tolerance = {"type": "number", "minimum": 0, "maximum": 0.01}
|
||||||
|
cylindrical = _closed_object({"diameter_mm": positive, "count": {"type": "integer", "minimum": 1}, "tolerance_mm": tolerance}, ["diameter_mm"])
|
||||||
|
outer_cylindrical = _closed_object({"diameter_mm": positive, "count": {"type": "integer", "minimum": 1}, "axial_span_mm": positive, "tolerance_mm": tolerance}, ["diameter_mm"])
|
||||||
|
bore_depth = _closed_object({"diameter_mm": positive, "depth_mm": positive, "count": {"type": "integer", "minimum": 1}, "tolerance_mm": tolerance}, ["diameter_mm", "depth_mm", "tolerance_mm"])
|
||||||
|
conical_bore = _closed_object({"small_diameter_mm": positive, "large_diameter_mm": positive, "included_angle_deg": {"type": "number", "exclusiveMinimum": 0, "maximum": 179.999}, "count": {"type": "integer", "minimum": 1}, "tolerance_mm": tolerance}, ["small_diameter_mm", "large_diameter_mm", "included_angle_deg", "tolerance_mm"])
|
||||||
|
bore_chain = _closed_object({
|
||||||
|
"diameter_mm": positive,
|
||||||
|
"adjacent_distances_mm": {"type": "array", "items": positive, "minItems": 1, "maxItems": 63},
|
||||||
|
"tolerance_mm": tolerance,
|
||||||
|
}, ["diameter_mm", "adjacent_distances_mm", "tolerance_mm"])
|
||||||
|
return VerifierRegistry((
|
||||||
|
ClaimDefinition("solid_count_equals", _closed_object({"value": {"type": "integer", "minimum": 1}}, ["value"]), ("rebuild_report",), _solid_count),
|
||||||
|
ClaimDefinition("volume_decreased", _closed_object({}, []), ("rebuild_report", "parent_rebuild_report"), _volume_decreased),
|
||||||
|
ClaimDefinition("single_connected_body", _closed_object({}, []), ("rebuild_report", "topology"), lambda _expected, facts: _solid_count({"value": 1}, facts)),
|
||||||
|
ClaimDefinition("bbox_dimension_mm", _closed_object({"axis": {"enum": ["x", "y", "z"]}, "value": positive, "tolerance_mm": tolerance}, ["axis", "value", "tolerance_mm"]), ("rebuild_report",), _bbox),
|
||||||
|
ClaimDefinition("bbox_rank_dimension_mm", _closed_object({"rank": {"enum": ["minimum", "median", "maximum"]}, "value": positive, "tolerance_mm": tolerance}, ["rank", "value", "tolerance_mm"]), ("rebuild_report",), _bbox_rank),
|
||||||
|
ClaimDefinition("cylindrical_bore", cylindrical, ("topology",), _cylindrical_bore),
|
||||||
|
ClaimDefinition("through_cylindrical_bore", cylindrical, ("topology",), _through_cylindrical_bore),
|
||||||
|
ClaimDefinition("cylindrical_bore_depth", bore_depth, ("topology",), _cylindrical_bore_depth),
|
||||||
|
ClaimDefinition("conical_bore", conical_bore, ("topology",), _conical_bore),
|
||||||
|
ClaimDefinition("outer_cylindrical_surface", outer_cylindrical, ("topology",), _outer_cylindrical_surface),
|
||||||
|
ClaimDefinition("circular_hole_pattern", _closed_object({"count": {"type": "integer", "minimum": 2}, "diameter_mm": positive, "pitch_radius_mm": positive, "concentric_bore_diameter_mm": positive, "tolerance_mm": tolerance}, ["count", "diameter_mm", "pitch_radius_mm", "tolerance_mm"]), ("topology",), _hole_pattern),
|
||||||
|
ClaimDefinition("collinear_through_bore_chain", bore_chain, ("topology",), _collinear_bore_chain),
|
||||||
|
ClaimDefinition("coaxial_through_bore_group", _closed_object({"diameter_mm": positive, "count": {"type": "integer", "minimum": 2, "maximum": 16}, "tolerance_mm": alignment_tolerance}, ["diameter_mm", "count", "tolerance_mm"]), ("topology",), _coaxial_through_bore_group),
|
||||||
|
ClaimDefinition("orthogonal_intersecting_through_bores", _closed_object({"first_diameter_mm": positive, "second_diameter_mm": positive, "first_axis": {"enum": ["x", "y", "z"]}, "second_axis": {"enum": ["x", "y", "z"]}, "tolerance_mm": alignment_tolerance}, ["first_diameter_mm", "second_diameter_mm", "first_axis", "second_axis", "tolerance_mm"]), ("topology",), _orthogonal_intersecting_through_bores),
|
||||||
|
ClaimDefinition("rectangular_corner_through_bore_pattern", _closed_object({"diameter_mm": positive, "count": {"const": 4}, "edge_offset_mm": positive, "tolerance_mm": tolerance}, ["diameter_mm", "count", "edge_offset_mm", "tolerance_mm"]), ("rebuild_report", "topology"), _rectangular_corner_bore_pattern),
|
||||||
|
ClaimDefinition("coaxial", _closed_object({"record_ids": {"type": "array", "items": {"type": "string", "minLength": 1}, "minItems": 2, "maxItems": 2, "uniqueItems": True}, "tolerance": alignment_tolerance}, ["record_ids", "tolerance"]), ("topology",), _coaxial),
|
||||||
|
ClaimDefinition("coplanar", _closed_object({"record_ids": {"type": "array", "items": {"type": "string", "minLength": 1}, "minItems": 2, "maxItems": 2, "uniqueItems": True}, "tolerance_mm": tolerance}, ["record_ids", "tolerance_mm"]), ("topology",), _coplanar),
|
||||||
|
ClaimDefinition("visual", _closed_object({"description": {"type": "string", "minLength": 1, "maxLength": 360}}, ["description"]), ("render",), lambda _expected, _facts: _pending("requires independent visual review"), deterministic=False),
|
||||||
|
))
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# Token Baseline Format
|
||||||
|
|
||||||
|
`python -m app.cad_agent.evals.live --suite release --require-live` requires
|
||||||
|
`--baseline-report` to point at a measured protocol-2 report. It is not a
|
||||||
|
fixture or an estimate. Record three independent runs of every release
|
||||||
|
scenario with the same author request configuration and runtime profile.
|
||||||
|
|
||||||
|
| Field | Requirement |
|
||||||
|
| --- | --- |
|
||||||
|
| `schema_version` | `cad.token-baseline.v1` |
|
||||||
|
| `protocol_version` | `2.0` |
|
||||||
|
| `author` | Exact `provider`, `model`, `api_style`, `reasoning_effort`, and `sampling` object from the v3 report |
|
||||||
|
| `runtime_profile_sha256` | Exact v3 `runtime_profile_sha256` |
|
||||||
|
| `measurement` | `prompt_tokens` when provider usage exists; otherwise `context_chars` |
|
||||||
|
| `scenarios[]` | One entry for every versioned release scenario, including its request SHA-256 |
|
||||||
|
| `repetitions[]` | Entries `1`, `2`, and `3`, with non-negative `author_metric` and `plan_review_metric`, plus completion, final-review, and deterministic-claim booleans |
|
||||||
|
|
||||||
|
The comparison uses `author_metric + plan_review_metric` for the v2 median and
|
||||||
|
the v3 author metric only. It fails closed when provenance differs, any
|
||||||
|
repetition is absent, completion/review/claim rates drop, plan-review calls
|
||||||
|
remain, or the median reduction is below 30%.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Live, non-mocked protocol v3 release evaluations."""
|
||||||
@@ -0,0 +1,423 @@
|
|||||||
|
{
|
||||||
|
"schema_version": "cad.comprehensive-prompt-fixtures.v1",
|
||||||
|
"source_document": "docs/cad-agent-v3-comprehensive-prompt-test-target.md",
|
||||||
|
"source_document_sha256": "79fbfeba41b2bf1fa37d2e90df1c4f5f06460f725f8898ae4238676bfea82d8b",
|
||||||
|
"scenarios": [
|
||||||
|
{
|
||||||
|
"id": "rectangular_mounting_plate",
|
||||||
|
"units": "mm",
|
||||||
|
"request": "生成一个CNC矩形安装板,长100毫米,宽60毫米,厚8毫米,四角圆角R6,四角各有一个直径8毫米的贯穿孔,孔中心距左右边10毫米、上下边10毫米,中心有一个直径30毫米的贯穿孔。",
|
||||||
|
"expected_phase": "COMPLETED",
|
||||||
|
"required_claim_kinds": ["solid_count_equals", "bbox_dimension_mm", "through_cylindrical_bore", "rectangular_corner_through_bore_pattern"],
|
||||||
|
"required_claims": [
|
||||||
|
{"claim_kind": "solid_count_equals", "expected": {"value": 1}},
|
||||||
|
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "x", "value": 100}},
|
||||||
|
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "y", "value": 60}},
|
||||||
|
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "z", "value": 8}},
|
||||||
|
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 8, "count": 4}},
|
||||||
|
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 30, "count": 1}},
|
||||||
|
{"claim_kind": "rectangular_corner_through_bore_pattern", "expected": {"diameter_mm": 8, "count": 4, "edge_offset_mm": 10}}
|
||||||
|
],
|
||||||
|
"required_atomic_ids": ["extrude_add_blind"],
|
||||||
|
"required_any_atomic_ids": ["hole_blind", "extrude_cut_blind"],
|
||||||
|
"validation_capability_gaps": [
|
||||||
|
{"id": "corner_radius", "description": "R6 outer corner radii"}
|
||||||
|
],
|
||||||
|
"max_author_turns": 56, "max_reviewer_turns": 20, "max_total_calls": 76, "max_wall_seconds": 1500, "max_total_tokens": 220000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "circular_flange_pcd",
|
||||||
|
"units": "mm",
|
||||||
|
"request": "生成一个圆形法兰盘,外径120毫米,厚度12毫米,中心贯穿孔直径40毫米,在直径90毫米的分度圆上均布6个直径8毫米的贯穿孔。",
|
||||||
|
"expected_phase": "COMPLETED",
|
||||||
|
"required_claim_kinds": ["solid_count_equals", "bbox_dimension_mm", "through_cylindrical_bore", "circular_hole_pattern"],
|
||||||
|
"required_claims": [
|
||||||
|
{"claim_kind": "solid_count_equals", "expected": {"value": 1}},
|
||||||
|
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "x", "value": 120}},
|
||||||
|
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "y", "value": 120}},
|
||||||
|
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "z", "value": 12}},
|
||||||
|
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 40, "count": 1}},
|
||||||
|
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 8, "count": 6}},
|
||||||
|
{"claim_kind": "circular_hole_pattern", "expected": {"diameter_mm": 8, "count": 6, "pitch_radius_mm": 45, "concentric_bore_diameter_mm": 40}}
|
||||||
|
],
|
||||||
|
"required_atomic_ids": [],
|
||||||
|
"required_any_atomic_ids": ["hole_blind", "extrude_cut_blind", "pattern_linear"],
|
||||||
|
"required_any_atomic_id_groups": [["extrude_add_blind", "revolve_add"]],
|
||||||
|
"validation_capability_gaps": [],
|
||||||
|
"max_author_turns": 52, "max_reviewer_turns": 20, "max_total_calls": 72, "max_wall_seconds": 1500, "max_total_tokens": 320000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "square_flange",
|
||||||
|
"units": "mm",
|
||||||
|
"request": "生成一个方形安装法兰,长100毫米,宽100毫米,厚度12毫米,四角圆角R8,中心贯穿孔直径45毫米,四角各有一个直径10毫米的安装孔,孔中心距相邻两边各15毫米。",
|
||||||
|
"expected_phase": "COMPLETED",
|
||||||
|
"required_claim_kinds": ["solid_count_equals", "bbox_dimension_mm", "through_cylindrical_bore", "rectangular_corner_through_bore_pattern"],
|
||||||
|
"required_claims": [
|
||||||
|
{"claim_kind": "solid_count_equals", "expected": {"value": 1}},
|
||||||
|
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "x", "value": 100}},
|
||||||
|
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "y", "value": 100}},
|
||||||
|
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "z", "value": 12}},
|
||||||
|
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 45, "count": 1}},
|
||||||
|
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 10, "count": 4}},
|
||||||
|
{"claim_kind": "rectangular_corner_through_bore_pattern", "expected": {"diameter_mm": 10, "count": 4, "edge_offset_mm": 15}}
|
||||||
|
],
|
||||||
|
"required_atomic_ids": ["extrude_add_blind"],
|
||||||
|
"required_any_atomic_ids": ["hole_blind", "extrude_cut_blind"],
|
||||||
|
"validation_capability_gaps": [
|
||||||
|
{"id": "corner_radius", "description": "R8 outer corner radii"}
|
||||||
|
],
|
||||||
|
"max_author_turns": 56, "max_reviewer_turns": 20, "max_total_calls": 76, "max_wall_seconds": 1500, "max_total_tokens": 220000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "counterbored_mounting_plate",
|
||||||
|
"units": "mm",
|
||||||
|
"request": "生成一个矩形安装板,长120毫米,宽80毫米,厚度15毫米,四角各有一个直径9毫米的贯穿孔,每个孔顶部带直径16毫米、深5毫米的圆柱沉孔,孔中心距相邻边各12毫米。",
|
||||||
|
"expected_phase": "COMPLETED",
|
||||||
|
"required_claim_kinds": ["solid_count_equals", "bbox_dimension_mm", "through_cylindrical_bore", "cylindrical_bore", "cylindrical_bore_depth", "rectangular_corner_through_bore_pattern"],
|
||||||
|
"required_claims": [
|
||||||
|
{"claim_kind": "solid_count_equals", "expected": {"value": 1}},
|
||||||
|
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "x", "value": 120}},
|
||||||
|
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "y", "value": 80}},
|
||||||
|
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "z", "value": 15}},
|
||||||
|
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 9, "count": 4}},
|
||||||
|
{"claim_kind": "cylindrical_bore", "expected": {"diameter_mm": 16, "count": 4}},
|
||||||
|
{"claim_kind": "cylindrical_bore_depth", "expected": {"diameter_mm": 16, "count": 4, "depth_mm": 5}},
|
||||||
|
{"claim_kind": "rectangular_corner_through_bore_pattern", "expected": {"diameter_mm": 9, "count": 4, "edge_offset_mm": 12}}
|
||||||
|
],
|
||||||
|
"required_atomic_ids": ["extrude_add_blind"],
|
||||||
|
"required_any_atomic_ids": ["hole_counterbore", "hole_wizard"],
|
||||||
|
"validation_capability_gaps": [],
|
||||||
|
"max_author_turns": 64, "max_reviewer_turns": 24, "max_total_calls": 88, "max_wall_seconds": 1800, "max_total_tokens": 250000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "countersunk_cover_plate",
|
||||||
|
"units": "mm",
|
||||||
|
"request": "生成一个盖板,长100毫米,宽70毫米,厚度8毫米,四角圆角R5,四角各有一个直径6.5毫米的贯穿孔,孔顶部带90度沉头,沉头最大直径12毫米。",
|
||||||
|
"expected_phase": "COMPLETED",
|
||||||
|
"required_claim_kinds": ["solid_count_equals", "bbox_dimension_mm", "through_cylindrical_bore", "conical_bore"],
|
||||||
|
"required_claims": [
|
||||||
|
{"claim_kind": "solid_count_equals", "expected": {"value": 1}},
|
||||||
|
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "x", "value": 100}},
|
||||||
|
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "y", "value": 70}},
|
||||||
|
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "z", "value": 8}},
|
||||||
|
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 6.5, "count": 4}},
|
||||||
|
{"claim_kind": "conical_bore", "expected": {"small_diameter_mm": 6.5, "large_diameter_mm": 12, "included_angle_deg": 90, "count": 4}}
|
||||||
|
],
|
||||||
|
"required_atomic_ids": ["extrude_add_blind"],
|
||||||
|
"required_any_atomic_ids": ["hole_countersink", "hole_wizard"],
|
||||||
|
"validation_capability_gaps": [
|
||||||
|
{"id": "corner_radius", "description": "R5 outer corner radii"}
|
||||||
|
],
|
||||||
|
"max_author_turns": 64, "max_reviewer_turns": 24, "max_total_calls": 88, "max_wall_seconds": 1800, "max_total_tokens": 250000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "obround_slot_plate",
|
||||||
|
"units": "mm",
|
||||||
|
"request": "生成一个连接板,长140毫米,宽50毫米,厚度10毫米,两端各有一个长圆形贯穿槽,槽总长30毫米、宽12毫米,槽中心距板端20毫米,槽的长轴沿板长度方向。",
|
||||||
|
"expected_phase": "COMPLETED",
|
||||||
|
"required_claim_kinds": ["solid_count_equals", "bbox_dimension_mm", "visual"],
|
||||||
|
"required_claims": [
|
||||||
|
{"claim_kind": "solid_count_equals", "expected": {"value": 1}},
|
||||||
|
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "x", "value": 140}},
|
||||||
|
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "y", "value": 50}},
|
||||||
|
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "z", "value": 10}}
|
||||||
|
],
|
||||||
|
"required_atomic_ids": ["extrude_add_blind"],
|
||||||
|
"required_any_atomic_ids": ["extrude_cut_blind"],
|
||||||
|
"validation_capability_gaps": [
|
||||||
|
{"id": "slot_dimensions", "description": "two through obround slots with 30 x 12 dimensions"},
|
||||||
|
{"id": "slot_position_orientation", "description": "20 mm end offset and lengthwise major-axis orientation"}
|
||||||
|
],
|
||||||
|
"max_author_turns": 64, "max_reviewer_turns": 24, "max_total_calls": 88, "max_wall_seconds": 1800, "max_total_tokens": 250000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t_slot_test_block",
|
||||||
|
"units": "mm",
|
||||||
|
"request": "生成一个T形槽试块,长100毫米,宽50毫米,高25毫米,在顶面中心沿长度方向加工一条T形槽,槽口宽10毫米、深8毫米,槽底宽20毫米、总深15毫米,槽贯穿试块两端。",
|
||||||
|
"expected_phase": "COMPLETED",
|
||||||
|
"required_claim_kinds": ["solid_count_equals", "bbox_dimension_mm", "visual"],
|
||||||
|
"required_claims": [
|
||||||
|
{"claim_kind": "solid_count_equals", "expected": {"value": 1}},
|
||||||
|
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "x", "value": 100}},
|
||||||
|
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "y", "value": 50}},
|
||||||
|
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "z", "value": 25}}
|
||||||
|
],
|
||||||
|
"required_atomic_ids": ["extrude_add_blind"],
|
||||||
|
"required_any_atomic_ids": ["extrude_cut_blind"],
|
||||||
|
"validation_capability_gaps": [
|
||||||
|
{"id": "t_slot_cross_section", "description": "10 x 8 slot mouth, 20 mm lower width and 15 mm total depth"},
|
||||||
|
{"id": "t_slot_through_orientation", "description": "top-centred T-slot through both lengthwise ends"}
|
||||||
|
],
|
||||||
|
"max_author_turns": 72, "max_reviewer_turns": 28, "max_total_calls": 100, "max_wall_seconds": 2100, "max_total_tokens": 280000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rounded_rectangular_pocket",
|
||||||
|
"units": "mm",
|
||||||
|
"request": "生成一个矩形底板,长120毫米,宽80毫米,厚20毫米,在顶面中心加工一个长80毫米、宽45毫米、深12毫米的矩形口袋,口袋四角圆角R6,底部保留8毫米厚度。",
|
||||||
|
"expected_phase": "COMPLETED",
|
||||||
|
"required_claim_kinds": ["solid_count_equals", "bbox_dimension_mm", "visual"],
|
||||||
|
"required_claims": [
|
||||||
|
{"claim_kind": "solid_count_equals", "expected": {"value": 1}},
|
||||||
|
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "x", "value": 120}},
|
||||||
|
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "y", "value": 80}},
|
||||||
|
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "z", "value": 20}}
|
||||||
|
],
|
||||||
|
"required_atomic_ids": ["extrude_add_blind"],
|
||||||
|
"required_any_atomic_ids": ["extrude_cut_blind"],
|
||||||
|
"validation_capability_gaps": [
|
||||||
|
{"id": "pocket_dimensions", "description": "centred 80 x 45 x 12 pocket with 8 mm remaining floor"},
|
||||||
|
{"id": "pocket_corner_radius", "description": "R6 internal pocket corners"}
|
||||||
|
],
|
||||||
|
"max_author_turns": 64, "max_reviewer_turns": 24, "max_total_calls": 88, "max_wall_seconds": 1800, "max_total_tokens": 250000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "two_level_pocket_plate",
|
||||||
|
"units": "mm",
|
||||||
|
"request": "生成一个CNC加工板,长140毫米,宽100毫米,厚25毫米。顶面中心先加工一个长100毫米、宽70毫米、深8毫米的矩形口袋,再在第一级口袋中心加工一个长60毫米、宽35毫米、额外深7毫米的第二级口袋,所有内角圆角R5。",
|
||||||
|
"expected_phase": "COMPLETED",
|
||||||
|
"required_claim_kinds": ["solid_count_equals", "bbox_dimension_mm", "visual"],
|
||||||
|
"required_claims": [
|
||||||
|
{"claim_kind": "solid_count_equals", "expected": {"value": 1}},
|
||||||
|
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "x", "value": 140}},
|
||||||
|
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "y", "value": 100}},
|
||||||
|
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "z", "value": 25}}
|
||||||
|
],
|
||||||
|
"required_atomic_ids": ["extrude_add_blind"],
|
||||||
|
"required_any_atomic_ids": ["extrude_cut_blind"],
|
||||||
|
"validation_capability_gaps": [
|
||||||
|
{"id": "pocket_levels", "description": "100 x 70 x 8 first pocket and centred 60 x 35 x 7 additional-depth second pocket"},
|
||||||
|
{"id": "pocket_corner_radii", "description": "R5 on every internal corner"}
|
||||||
|
],
|
||||||
|
"max_author_turns": 80, "max_reviewer_turns": 30, "max_total_calls": 110, "max_wall_seconds": 2400, "max_total_tokens": 320000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "cross_drilled_valve_block",
|
||||||
|
"units": "mm",
|
||||||
|
"request": "生成一个阀块试件,长80毫米,宽60毫米,高50毫米。沿长度方向加工一个直径20毫米的贯穿孔,沿宽度方向加工一个直径12毫米的贯穿孔,两个孔的轴线在零件中心相交。",
|
||||||
|
"expected_phase": "COMPLETED",
|
||||||
|
"required_claim_kinds": ["solid_count_equals", "bbox_dimension_mm", "through_cylindrical_bore", "orthogonal_intersecting_through_bores"],
|
||||||
|
"required_claims": [
|
||||||
|
{"claim_kind": "solid_count_equals", "expected": {"value": 1}},
|
||||||
|
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "x", "value": 80}},
|
||||||
|
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "y", "value": 60}},
|
||||||
|
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "z", "value": 50}},
|
||||||
|
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 20, "count": 1}},
|
||||||
|
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 12, "count": 1}},
|
||||||
|
{"claim_kind": "orthogonal_intersecting_through_bores", "expected": {"first_diameter_mm": 20, "second_diameter_mm": 12, "first_axis": "x", "second_axis": "y"}}
|
||||||
|
],
|
||||||
|
"required_atomic_ids": ["extrude_add_blind"],
|
||||||
|
"required_any_atomic_ids": ["hole_blind", "extrude_cut_blind"],
|
||||||
|
"validation_capability_gaps": [],
|
||||||
|
"max_author_turns": 72, "max_reviewer_turns": 28, "max_total_calls": 100, "max_wall_seconds": 2100, "max_total_tokens": 280000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "double_hole_linkage_arm",
|
||||||
|
"units": "mm",
|
||||||
|
"request": "生成一个机械臂双孔连杆,两个销孔中心距120毫米,连杆厚度12毫米,两端外圆直径40毫米,两个销孔直径16毫米,中间杆身最小宽度24毫米,轮廓平滑相切,中部设置三个直径14毫米的减重贯穿孔。",
|
||||||
|
"expected_phase": "COMPLETED",
|
||||||
|
"required_claim_kinds": ["solid_count_equals", "bbox_rank_dimension_mm", "through_cylindrical_bore", "collinear_through_bore_chain", "visual"],
|
||||||
|
"required_claims": [
|
||||||
|
{"claim_kind": "solid_count_equals", "expected": {"value": 1}},
|
||||||
|
{"claim_kind": "bbox_rank_dimension_mm", "expected": {"rank": "minimum", "value": 12}},
|
||||||
|
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 16, "count": 2}},
|
||||||
|
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 14, "count": 3}},
|
||||||
|
{"claim_kind": "collinear_through_bore_chain", "expected": {"diameter_mm": 16, "adjacent_distances_mm": [120], "tolerance_mm": 0.1}},
|
||||||
|
{"claim_kind": "visual", "expected": {}}
|
||||||
|
],
|
||||||
|
"required_atomic_ids": ["extrude_add_blind"],
|
||||||
|
"required_any_atomic_ids": ["hole_blind", "extrude_cut_blind"],
|
||||||
|
"validation_capability_gaps": [
|
||||||
|
{"id": "linkage_outline", "description": "40 mm end circles, 24 mm minimum web width and smooth tangency"},
|
||||||
|
{"id": "lightening_holes", "description": "three 14 mm central through holes"}
|
||||||
|
],
|
||||||
|
"max_author_turns": 84, "max_reviewer_turns": 32, "max_total_calls": 116, "max_wall_seconds": 2400, "max_total_tokens": 340000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "three_hole_linkage",
|
||||||
|
"units": "mm",
|
||||||
|
"request": "生成一个三孔机械连杆,三个孔的中心位于同一直线上,相邻孔中心距分别为60毫米和80毫米,三个孔直径均为12毫米,连杆厚度10毫米,每个孔周围外圆直径32毫米,各段外轮廓平滑连接。",
|
||||||
|
"expected_phase": "COMPLETED",
|
||||||
|
"required_claim_kinds": ["solid_count_equals", "bbox_rank_dimension_mm", "through_cylindrical_bore", "collinear_through_bore_chain", "visual"],
|
||||||
|
"required_claims": [
|
||||||
|
{"claim_kind": "solid_count_equals", "expected": {"value": 1}},
|
||||||
|
{"claim_kind": "bbox_rank_dimension_mm", "expected": {"rank": "minimum", "value": 10}},
|
||||||
|
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 12, "count": 3}},
|
||||||
|
{"claim_kind": "collinear_through_bore_chain", "expected": {"diameter_mm": 12, "adjacent_distances_mm": [60, 80], "tolerance_mm": 0.1}},
|
||||||
|
{"claim_kind": "visual", "expected": {}}
|
||||||
|
],
|
||||||
|
"required_atomic_ids": ["extrude_add_blind"],
|
||||||
|
"required_any_atomic_ids": ["hole_blind", "extrude_cut_blind"],
|
||||||
|
"validation_capability_gaps": [
|
||||||
|
{"id": "linkage_outer_circles", "description": "32 mm outer circles with smoothly connected segments"}
|
||||||
|
],
|
||||||
|
"max_author_turns": 84, "max_reviewer_turns": 32, "max_total_calls": 116, "max_wall_seconds": 2400, "max_total_tokens": 340000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "l_bracket",
|
||||||
|
"units": "mm",
|
||||||
|
"request": "生成一个整体式L形角码,水平底板长80毫米、宽50毫米、厚8毫米,竖直板高60毫米、宽50毫米、厚8毫米,两板成90度。水平板上有两个直径8毫米贯穿孔,竖直板上有两个直径8毫米贯穿孔,孔左右对称。",
|
||||||
|
"expected_phase": "COMPLETED",
|
||||||
|
"required_claim_kinds": ["single_connected_body", "through_cylindrical_bore", "visual"],
|
||||||
|
"required_claims": [
|
||||||
|
{"claim_kind": "single_connected_body", "expected": {}},
|
||||||
|
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 8, "count": 4}},
|
||||||
|
{"claim_kind": "visual", "expected": {}}
|
||||||
|
],
|
||||||
|
"required_atomic_ids": ["extrude_add_blind"],
|
||||||
|
"required_any_atomic_ids": ["hole_blind", "extrude_cut_blind"],
|
||||||
|
"validation_capability_gaps": [
|
||||||
|
{"id": "l_bracket_dimensions", "description": "base and upright dimensions, thicknesses and 90 degree relationship"},
|
||||||
|
{"id": "l_bracket_hole_symmetry", "description": "two symmetric 8 mm through holes on each plate"}
|
||||||
|
],
|
||||||
|
"max_author_turns": 88, "max_reviewer_turns": 32, "max_total_calls": 120, "max_wall_seconds": 2700, "max_total_tokens": 360000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ribbed_l_bracket",
|
||||||
|
"units": "mm",
|
||||||
|
"request": "生成一个整体式L形机械支架,底板长100毫米、宽60毫米、厚10毫米,竖板高80毫米、宽60毫米、厚10毫米,两板成90度。底板和竖板之间设置两个厚度8毫米的三角加强筋。底板有四个直径9毫米贯穿孔,竖板中心有一个直径30毫米贯穿孔。",
|
||||||
|
"expected_phase": "COMPLETED",
|
||||||
|
"required_claim_kinds": ["single_connected_body", "through_cylindrical_bore", "visual"],
|
||||||
|
"required_claims": [
|
||||||
|
{"claim_kind": "single_connected_body", "expected": {}},
|
||||||
|
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 9, "count": 4}},
|
||||||
|
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 30, "count": 1}},
|
||||||
|
{"claim_kind": "visual", "expected": {}}
|
||||||
|
],
|
||||||
|
"required_atomic_ids": ["extrude_add_blind"],
|
||||||
|
"required_any_atomic_ids": ["hole_blind", "extrude_cut_blind"],
|
||||||
|
"validation_capability_gaps": [
|
||||||
|
{"id": "bracket_dimensions", "description": "100 x 60 x 10 base and 80 x 60 x 10 upright at 90 degrees"},
|
||||||
|
{"id": "triangular_ribs", "description": "two triangular 8 mm thick ribs"},
|
||||||
|
{"id": "bracket_hole_placement", "description": "four base 9 mm holes and centred upright 30 mm hole"}
|
||||||
|
],
|
||||||
|
"max_author_turns": 96, "max_reviewer_turns": 36, "max_total_calls": 132, "max_wall_seconds": 3000, "max_total_tokens": 400000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "u_bearing_support",
|
||||||
|
"units": "mm",
|
||||||
|
"request": "生成一个U形轴承支座,底板长100毫米、宽60毫米、厚12毫米,两侧竖耳厚12毫米、高55毫米,两个竖耳内侧间距40毫米。两个竖耳上各有一个直径20毫米的同轴贯穿孔,孔轴线距底板上表面35毫米。底板四角各有一个直径8毫米安装孔。",
|
||||||
|
"expected_phase": "COMPLETED",
|
||||||
|
"required_claim_kinds": ["single_connected_body", "through_cylindrical_bore", "coaxial_through_bore_group", "visual"],
|
||||||
|
"required_claims": [
|
||||||
|
{"claim_kind": "single_connected_body", "expected": {}},
|
||||||
|
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 20, "count": 2}},
|
||||||
|
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 8, "count": 4}},
|
||||||
|
{"claim_kind": "coaxial_through_bore_group", "expected": {"diameter_mm": 20, "count": 2}},
|
||||||
|
{"claim_kind": "visual", "expected": {}}
|
||||||
|
],
|
||||||
|
"required_atomic_ids": ["extrude_add_blind"],
|
||||||
|
"required_any_atomic_ids": ["hole_blind", "extrude_cut_blind"],
|
||||||
|
"validation_capability_gaps": [
|
||||||
|
{"id": "u_support_dimensions", "description": "base and ear dimensions, ear spacing and 35 mm axis height"},
|
||||||
|
{"id": "base_hole_positions", "description": "four 8 mm base mounting-hole positions"}
|
||||||
|
],
|
||||||
|
"max_author_turns": 96, "max_reviewer_turns": 36, "max_total_calls": 132, "max_wall_seconds": 3000, "max_total_tokens": 400000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "double_lug_mount",
|
||||||
|
"units": "mm",
|
||||||
|
"request": "生成一个双耳连接座,底座长90毫米、宽60毫米、厚12毫米,底座上有两个平行耳板,每个耳板厚10毫米、高50毫米,两耳板内侧间距30毫米。两个耳板上各有一个直径16毫米的同轴贯穿销孔,孔中心距底座上表面32毫米。",
|
||||||
|
"expected_phase": "COMPLETED",
|
||||||
|
"required_claim_kinds": ["single_connected_body", "through_cylindrical_bore", "coaxial_through_bore_group", "visual"],
|
||||||
|
"required_claims": [
|
||||||
|
{"claim_kind": "single_connected_body", "expected": {}},
|
||||||
|
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 16, "count": 2}},
|
||||||
|
{"claim_kind": "coaxial_through_bore_group", "expected": {"diameter_mm": 16, "count": 2}},
|
||||||
|
{"claim_kind": "visual", "expected": {}}
|
||||||
|
],
|
||||||
|
"required_atomic_ids": ["extrude_add_blind"],
|
||||||
|
"required_any_atomic_ids": ["hole_blind", "extrude_cut_blind"],
|
||||||
|
"validation_capability_gaps": [
|
||||||
|
{"id": "lug_dimensions", "description": "base, lug thickness/height, 30 mm inside spacing and 32 mm bore-axis height"}
|
||||||
|
],
|
||||||
|
"max_author_turns": 92, "max_reviewer_turns": 34, "max_total_calls": 126, "max_wall_seconds": 2700, "max_total_tokens": 380000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "stepped_shaft",
|
||||||
|
"units": "mm",
|
||||||
|
"request": "生成一根阶梯轴,总长120毫米。第一段直径30毫米、长40毫米;第二段直径24毫米、长50毫米;第三段直径18毫米、长30毫米。所有轴肩过渡圆角R2,两端倒角1毫米乘45度。",
|
||||||
|
"expected_phase": "COMPLETED",
|
||||||
|
"required_claim_kinds": ["single_connected_body", "bbox_rank_dimension_mm", "outer_cylindrical_surface", "visual"],
|
||||||
|
"required_claims": [
|
||||||
|
{"claim_kind": "single_connected_body", "expected": {}},
|
||||||
|
{"claim_kind": "bbox_rank_dimension_mm", "expected": {"rank": "maximum", "value": 120}},
|
||||||
|
{"claim_kind": "outer_cylindrical_surface", "expected": {"diameter_mm": 30, "count": 1}},
|
||||||
|
{"claim_kind": "outer_cylindrical_surface", "expected": {"diameter_mm": 24, "count": 1}},
|
||||||
|
{"claim_kind": "outer_cylindrical_surface", "expected": {"diameter_mm": 18, "count": 1}},
|
||||||
|
{"claim_kind": "visual", "expected": {}}
|
||||||
|
],
|
||||||
|
"required_atomic_ids": ["revolve_add"],
|
||||||
|
"required_any_atomic_ids": ["fillet", "chamfer"],
|
||||||
|
"validation_capability_gaps": [
|
||||||
|
{"id": "shaft_segment_lengths", "description": "the 30 mm, 24 mm and 18 mm shaft sections have axial lengths 40 mm, 50 mm and 30 mm"},
|
||||||
|
{"id": "shaft_finish_features", "description": "R2 shoulders and 1 x 45 degree end chamfers"}
|
||||||
|
],
|
||||||
|
"max_author_turns": 72, "max_reviewer_turns": 28, "max_total_calls": 100, "max_wall_seconds": 2100, "max_total_tokens": 280000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "keyed_stepped_shaft",
|
||||||
|
"units": "mm",
|
||||||
|
"request": "生成一根阶梯传动轴,总长140毫米,中间轴段直径30毫米、长70毫米,两端轴段直径20毫米、各长35毫米。中间轴段沿轴向加工一条平键槽,键槽宽8毫米、深3.3毫米、长50毫米,所有轴肩圆角R2。",
|
||||||
|
"expected_phase": "COMPLETED",
|
||||||
|
"required_claim_kinds": ["single_connected_body", "bbox_rank_dimension_mm", "outer_cylindrical_surface", "visual"],
|
||||||
|
"required_claims": [
|
||||||
|
{"claim_kind": "single_connected_body", "expected": {}},
|
||||||
|
{"claim_kind": "bbox_rank_dimension_mm", "expected": {"rank": "maximum", "value": 140}},
|
||||||
|
{"claim_kind": "outer_cylindrical_surface", "expected": {"diameter_mm": 30, "count": 1}},
|
||||||
|
{"claim_kind": "outer_cylindrical_surface", "expected": {"diameter_mm": 20, "count": 2}},
|
||||||
|
{"claim_kind": "visual", "expected": {}}
|
||||||
|
],
|
||||||
|
"required_atomic_ids": ["revolve_add"],
|
||||||
|
"required_any_atomic_ids": ["extrude_cut_blind", "fillet"],
|
||||||
|
"validation_capability_gaps": [
|
||||||
|
{"id": "shaft_segment_lengths", "description": "the 20 mm end sections are 35 mm each and the 30 mm middle section is 70 mm"},
|
||||||
|
{"id": "keyway", "description": "8 x 3.3 x 50 axial keyway on the middle shaft section"},
|
||||||
|
{"id": "shoulder_fillet", "description": "R2 on all shaft shoulders"}
|
||||||
|
],
|
||||||
|
"max_author_turns": 84, "max_reviewer_turns": 32, "max_total_calls": 116, "max_wall_seconds": 2400, "max_total_tokens": 340000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "flanged_sleeve",
|
||||||
|
"units": "mm",
|
||||||
|
"request": "生成一个机械套筒,外径50毫米,内孔直径30毫米,总长60毫米。套筒一端带外径70毫米、厚度10毫米的法兰,法兰上在直径56毫米分度圆上均布4个直径7毫米贯穿孔。",
|
||||||
|
"expected_phase": "COMPLETED",
|
||||||
|
"required_claim_kinds": ["single_connected_body", "bbox_rank_dimension_mm", "through_cylindrical_bore", "circular_hole_pattern", "outer_cylindrical_surface"],
|
||||||
|
"required_claims": [
|
||||||
|
{"claim_kind": "single_connected_body", "expected": {}},
|
||||||
|
{"claim_kind": "bbox_rank_dimension_mm", "expected": {"rank": "minimum", "value": 60}},
|
||||||
|
{"claim_kind": "bbox_rank_dimension_mm", "expected": {"rank": "maximum", "value": 70}},
|
||||||
|
{"claim_kind": "outer_cylindrical_surface", "expected": {"diameter_mm": 50, "count": 1, "axial_span_mm": 50}},
|
||||||
|
{"claim_kind": "outer_cylindrical_surface", "expected": {"diameter_mm": 70, "count": 1, "axial_span_mm": 10}},
|
||||||
|
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 30, "count": 1}},
|
||||||
|
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 7, "count": 4}},
|
||||||
|
{"claim_kind": "circular_hole_pattern", "expected": {"diameter_mm": 7, "count": 4, "pitch_radius_mm": 28, "concentric_bore_diameter_mm": 30}}
|
||||||
|
],
|
||||||
|
"required_atomic_ids": ["revolve_add"],
|
||||||
|
"required_any_atomic_ids": ["hole_blind", "extrude_cut_blind"],
|
||||||
|
"validation_capability_gaps": [
|
||||||
|
{"id": "flange_at_one_end", "description": "the 70 mm flange occurs at exactly one end of the coaxial sleeve, not at an interior position"}
|
||||||
|
],
|
||||||
|
"max_author_turns": 84, "max_reviewer_turns": 32, "max_total_calls": 116, "max_wall_seconds": 2400, "max_total_tokens": 340000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "chamfered_bushing",
|
||||||
|
"units": "mm",
|
||||||
|
"request": "生成一个圆柱轴套,外径40毫米,内孔直径25毫米,长度50毫米,两端外边缘倒角1.5毫米乘45度,两端内孔边缘倒角1毫米乘45度。",
|
||||||
|
"expected_phase": "COMPLETED",
|
||||||
|
"required_claim_kinds": ["single_connected_body", "bbox_rank_dimension_mm", "through_cylindrical_bore", "visual"],
|
||||||
|
"required_claims": [
|
||||||
|
{"claim_kind": "single_connected_body", "expected": {}},
|
||||||
|
{"claim_kind": "bbox_rank_dimension_mm", "expected": {"rank": "minimum", "value": 40}},
|
||||||
|
{"claim_kind": "bbox_rank_dimension_mm", "expected": {"rank": "maximum", "value": 50}},
|
||||||
|
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 25, "count": 1}},
|
||||||
|
{"claim_kind": "visual", "expected": {}}
|
||||||
|
],
|
||||||
|
"required_atomic_ids": ["revolve_add"],
|
||||||
|
"required_any_atomic_ids": ["chamfer"],
|
||||||
|
"validation_capability_gaps": [
|
||||||
|
{"id": "inside_outside_chamfers", "description": "both 1.5 x 45 degree external and 1 x 45 degree internal chamfers"}
|
||||||
|
],
|
||||||
|
"max_author_turns": 72, "max_reviewer_turns": 28, "max_total_calls": 100, "max_wall_seconds": 2100, "max_total_tokens": 280000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
{
|
||||||
|
"schema_version": "cad.live-eval-fixtures.v1",
|
||||||
|
"scenarios": [
|
||||||
|
{
|
||||||
|
"id": "rectangular_plate",
|
||||||
|
"units": "mm",
|
||||||
|
"request": "Create one connected rectangular plate, 80 mm long, 50 mm wide, and 8 mm thick. Use millimetres. Verify the single solid and all three bounding-box dimensions.",
|
||||||
|
"expected_phase": "COMPLETED",
|
||||||
|
"required_claim_kinds": ["solid_count_equals", "bbox_dimension_mm"],
|
||||||
|
"required_claims": [
|
||||||
|
{"claim_kind": "solid_count_equals", "expected": {"value": 1}},
|
||||||
|
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "x", "value": 80, "tolerance_mm": 0.01}},
|
||||||
|
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "y", "value": 50, "tolerance_mm": 0.01}},
|
||||||
|
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "z", "value": 8, "tolerance_mm": 0.01}}
|
||||||
|
],
|
||||||
|
"required_atomic_ids": ["extrude_add_blind"],
|
||||||
|
"max_author_turns": 32,
|
||||||
|
"max_reviewer_turns": 12,
|
||||||
|
"max_total_calls": 44,
|
||||||
|
"max_wall_seconds": 900,
|
||||||
|
"max_total_tokens": 120000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "simple_flange",
|
||||||
|
"units": "mm",
|
||||||
|
"request": "Create one connected round flange in millimetres: outer diameter 120 mm, thickness 12 mm, and a centered 40 mm through bore. Verify the single solid, thickness, and through bore topology.",
|
||||||
|
"expected_phase": "COMPLETED",
|
||||||
|
"required_claim_kinds": ["solid_count_equals", "bbox_dimension_mm", "through_cylindrical_bore"],
|
||||||
|
"required_claims": [
|
||||||
|
{"claim_kind": "solid_count_equals", "expected": {"value": 1}},
|
||||||
|
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "x", "value": 120, "tolerance_mm": 0.01}},
|
||||||
|
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "y", "value": 120, "tolerance_mm": 0.01}},
|
||||||
|
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "z", "value": 12, "tolerance_mm": 0.01}},
|
||||||
|
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 40, "count": 1, "tolerance_mm": 0.01}}
|
||||||
|
],
|
||||||
|
"required_atomic_ids": ["extrude_add_blind"],
|
||||||
|
"required_any_atomic_ids": ["hole_blind", "extrude_cut_blind"],
|
||||||
|
"max_author_turns": 40,
|
||||||
|
"max_reviewer_turns": 16,
|
||||||
|
"max_total_calls": 56,
|
||||||
|
"max_wall_seconds": 1200,
|
||||||
|
"max_total_tokens": 160000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ribbed_mounting_plate",
|
||||||
|
"units": "mm",
|
||||||
|
"request": "Create one connected mounting plate in millimetres with a rectangular base, a vertical reinforcing rib, and four equally spaced mounting through holes. State reasonable dimensions as assumptions, then verify a single connected solid and the four-hole pattern.",
|
||||||
|
"expected_phase": "COMPLETED",
|
||||||
|
"required_claim_kinds": ["solid_count_equals", "circular_hole_pattern", "through_cylindrical_bore"],
|
||||||
|
"required_claims": [
|
||||||
|
{"claim_kind": "solid_count_equals", "expected": {"value": 1}}
|
||||||
|
],
|
||||||
|
"required_atomic_ids": ["extrude_add_blind", "hole_blind"],
|
||||||
|
"max_author_turns": 56,
|
||||||
|
"max_reviewer_turns": 20,
|
||||||
|
"max_total_calls": 76,
|
||||||
|
"max_wall_seconds": 1500,
|
||||||
|
"max_total_tokens": 200000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "selector_finish",
|
||||||
|
"units": "mm",
|
||||||
|
"request": "Create one connected rectangular plate with an added rounded or chamfered edge selected from the current topology. Use millimetres, state assumptions, and verify the final single solid.",
|
||||||
|
"expected_phase": "COMPLETED",
|
||||||
|
"required_claim_kinds": ["solid_count_equals"],
|
||||||
|
"required_claims": [
|
||||||
|
{"claim_kind": "solid_count_equals", "expected": {"value": 1}}
|
||||||
|
],
|
||||||
|
"required_atomic_ids": ["extrude_add_blind"],
|
||||||
|
"required_any_atomic_ids": ["fillet", "chamfer"],
|
||||||
|
"max_author_turns": 40,
|
||||||
|
"max_reviewer_turns": 16,
|
||||||
|
"max_total_calls": 56,
|
||||||
|
"max_wall_seconds": 1200,
|
||||||
|
"max_total_tokens": 160000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,736 @@
|
|||||||
|
"""Run the real provider, engine, reviewer and v3 state machine end to end.
|
||||||
|
|
||||||
|
``--require-live`` deliberately fails closed: missing credentials, blocked
|
||||||
|
network, a skipped model capability, a timeout, or any scenario failure emits
|
||||||
|
``LIVE_EVAL_BLOCKED``/failure details and exits non-zero.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
from dataclasses import replace
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from hashlib import sha256
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import secrets
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.cad_agent.application.capabilities import verify_model_capability
|
||||||
|
from app.cad_agent.application.workflow import ModelIdentity
|
||||||
|
from app.cad_agent.composition import compose_v3
|
||||||
|
from app.cad_agent.domain.claim_matching import contains_expected
|
||||||
|
from app.cad_agent.domain.operation_contract import canonical_hash
|
||||||
|
from app.cad_agent.domain.verifier_registry import default_registry
|
||||||
|
from app.cad_agent.evals.token_baseline import (
|
||||||
|
TokenBaselineError,
|
||||||
|
author_request_identity,
|
||||||
|
compare_token_baseline,
|
||||||
|
load_token_baseline,
|
||||||
|
profile_sha256,
|
||||||
|
validate_token_baseline_provenance,
|
||||||
|
)
|
||||||
|
from app.settings import BACKEND_ROOT, get_settings
|
||||||
|
|
||||||
|
|
||||||
|
_CANDIDATE_EVIDENCE_FILES = frozenset({
|
||||||
|
"candidate.json",
|
||||||
|
"candidate-review.json",
|
||||||
|
"model.cdsl.json",
|
||||||
|
"model.step",
|
||||||
|
"model.glb",
|
||||||
|
"model.topology.json",
|
||||||
|
"rebuild-report.json",
|
||||||
|
"renders/render-manifest.json",
|
||||||
|
"renders/contact-sheet.jpg",
|
||||||
|
})
|
||||||
|
|
||||||
|
_FAILURE_LAYERS = frozenset({
|
||||||
|
"model_format_or_decision",
|
||||||
|
"v3_contract_or_verifier",
|
||||||
|
"cdsl_expression",
|
||||||
|
"engine_execution",
|
||||||
|
"independent_visual_review",
|
||||||
|
"configuration_or_network",
|
||||||
|
})
|
||||||
|
|
||||||
|
_ERROR_FAILURE_LAYERS = {
|
||||||
|
"AUTHOR_FORMAT_INVALID": "model_format_or_decision",
|
||||||
|
"AUTHOR_DECISION_REJECTED": "model_format_or_decision",
|
||||||
|
"STALE_WORKING_HEAD": "model_format_or_decision",
|
||||||
|
"FAILED_AUTHOR_FORMAT": "model_format_or_decision",
|
||||||
|
"MODEL_STRUCTURED_OUTPUT_UNSUPPORTED": "model_format_or_decision",
|
||||||
|
"RUNTIME_PRECONDITION_FAILED": "v3_contract_or_verifier",
|
||||||
|
"RUNTIME_CONTRACT_INVALID": "v3_contract_or_verifier",
|
||||||
|
"VERIFIER_UNAVAILABLE": "v3_contract_or_verifier",
|
||||||
|
"CLAIM_VERIFICATION_FAILED": "cdsl_expression",
|
||||||
|
"CANDIDATE_BUILD_FAILED": "engine_execution",
|
||||||
|
"CANDIDATE_REVIEW_REJECTED": "independent_visual_review",
|
||||||
|
"AUTHOR_TRANSPORT_UNAVAILABLE": "configuration_or_network",
|
||||||
|
"REVIEW_SERVICE_UNAVAILABLE": "configuration_or_network",
|
||||||
|
"RENDER_SERVICE_UNAVAILABLE": "configuration_or_network",
|
||||||
|
"STORAGE_FAILURE": "configuration_or_network",
|
||||||
|
"LIVE_EVAL_TIMEOUT": "configuration_or_network",
|
||||||
|
"FAILED_INTERNAL": "engine_execution",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _arguments() -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(description="Run live protocol-v3 CAD evaluations.")
|
||||||
|
parser.add_argument("--suite", choices=("smoke", "release", "comprehensive"), default="smoke")
|
||||||
|
parser.add_argument("--require-live", action="store_true")
|
||||||
|
parser.add_argument("--allow-skip", action="store_true")
|
||||||
|
parser.add_argument("--author-provider")
|
||||||
|
parser.add_argument("--author-model")
|
||||||
|
parser.add_argument("--review-provider")
|
||||||
|
parser.add_argument("--review-model")
|
||||||
|
parser.add_argument("--scenario", help="Run one fixture scenario by its stable ID for targeted regression validation.")
|
||||||
|
parser.add_argument("--baseline-report", type=Path, help="Measured pre-v3 token baseline JSON for a release run.")
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def _fixture(suite: str, scenario_id: str | None = None) -> list[dict[str, Any]]:
|
||||||
|
fixture_name = "comprehensive.json" if suite == "comprehensive" else "release.json"
|
||||||
|
value = json.loads((Path(__file__).parent / "fixtures" / fixture_name).read_text(encoding="utf-8"))
|
||||||
|
if fixture_name == "comprehensive.json":
|
||||||
|
source_document = str(value.get("source_document") or "")
|
||||||
|
expected_digest = str(value.get("source_document_sha256") or "")
|
||||||
|
source_path = (BACKEND_ROOT.parent / source_document).resolve()
|
||||||
|
workspace_root = BACKEND_ROOT.parent.resolve()
|
||||||
|
if (
|
||||||
|
not source_document
|
||||||
|
or workspace_root not in source_path.parents
|
||||||
|
or not source_path.is_file()
|
||||||
|
or sha256(source_path.read_bytes()).hexdigest() != expected_digest
|
||||||
|
):
|
||||||
|
raise ValueError("Comprehensive fixture is not synchronized with its source document.")
|
||||||
|
values = [item for item in value.get("scenarios") or () if isinstance(item, dict)]
|
||||||
|
values = values[:2] if suite == "smoke" else values
|
||||||
|
if scenario_id is None:
|
||||||
|
return values
|
||||||
|
selected = [item for item in values if str(item.get("id") or "") == scenario_id]
|
||||||
|
if not selected:
|
||||||
|
raise ValueError(f"Unknown scenario {scenario_id!r} for suite {suite!r}")
|
||||||
|
return selected
|
||||||
|
|
||||||
|
|
||||||
|
def _rejection_codes(events: list[dict[str, Any]]) -> list[str]:
|
||||||
|
codes: list[str] = []
|
||||||
|
for event in events:
|
||||||
|
payload = event.get("payload") if isinstance(event.get("payload"), dict) else {}
|
||||||
|
result = payload.get("result") if isinstance(payload.get("result"), dict) else payload
|
||||||
|
code = result.get("code") if isinstance(result, dict) else None
|
||||||
|
if code in {"AUTHOR_FORMAT_INVALID", "AUTHOR_DECISION_REJECTED", "STALE_WORKING_HEAD"}:
|
||||||
|
codes.append(str(code))
|
||||||
|
return codes
|
||||||
|
|
||||||
|
|
||||||
|
def _acceptance_coverage(
|
||||||
|
scenario: dict[str, Any],
|
||||||
|
requirements_contract: dict[str, Any] | None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Assess whether a fixture's acceptance contract is representable today.
|
||||||
|
|
||||||
|
Comprehensive prompts deliberately include manufacturing relationships that
|
||||||
|
may not yet have a deterministic verifier. A healthy-looking solid is
|
||||||
|
not evidence for those relationships, so they remain explicit capability
|
||||||
|
gaps instead of silently passing a scenario.
|
||||||
|
"""
|
||||||
|
actual = [
|
||||||
|
{
|
||||||
|
"claim_kind": str(claim.get("claim_kind") or ""),
|
||||||
|
"expected": claim.get("expected") if isinstance(claim.get("expected"), dict) else {},
|
||||||
|
}
|
||||||
|
for requirement in (requirements_contract or {}).get("requirements") or ()
|
||||||
|
if isinstance(requirement, dict)
|
||||||
|
for claim in requirement.get("acceptance_claims") or ()
|
||||||
|
if isinstance(claim, dict)
|
||||||
|
]
|
||||||
|
required = {str(value) for value in scenario.get("required_claim_kinds") or ()}
|
||||||
|
expected_claims = [
|
||||||
|
{
|
||||||
|
"claim_kind": str(item.get("claim_kind") or ""),
|
||||||
|
"expected": item.get("expected") if isinstance(item.get("expected"), dict) else {},
|
||||||
|
}
|
||||||
|
for item in scenario.get("required_claims") or ()
|
||||||
|
if isinstance(item, dict) and isinstance(item.get("claim_kind"), str)
|
||||||
|
]
|
||||||
|
expected_kinds = {claim["claim_kind"] for claim in expected_claims}
|
||||||
|
expected_claims.extend(
|
||||||
|
{"claim_kind": claim_kind, "expected": {}}
|
||||||
|
for claim_kind in sorted(required - expected_kinds)
|
||||||
|
)
|
||||||
|
gaps = [
|
||||||
|
{"id": str(item.get("id") or ""), "description": str(item.get("description") or "")}
|
||||||
|
for item in scenario.get("validation_capability_gaps") or ()
|
||||||
|
if isinstance(item, dict)
|
||||||
|
]
|
||||||
|
missing = [
|
||||||
|
claim for claim in expected_claims
|
||||||
|
if not any(
|
||||||
|
actual_claim["claim_kind"] == claim["claim_kind"]
|
||||||
|
and contains_expected(actual_claim["expected"], claim["expected"])
|
||||||
|
for actual_claim in actual
|
||||||
|
)
|
||||||
|
]
|
||||||
|
return {
|
||||||
|
"required_claim_kinds": sorted(required),
|
||||||
|
"covered_claim_kinds": sorted(required.intersection({claim["claim_kind"] for claim in actual})),
|
||||||
|
"required_claims": expected_claims,
|
||||||
|
"missing_claims": missing,
|
||||||
|
"validation_capability_gaps": gaps,
|
||||||
|
"complete": not missing and not gaps,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _contains_expected(actual: Any, required: Any) -> bool:
|
||||||
|
"""Backward-compatible evaluation helper for canonical claim matching."""
|
||||||
|
return contains_expected(actual, required)
|
||||||
|
|
||||||
|
|
||||||
|
def _capability_block_reason(*reports: dict[str, Any]) -> tuple[str, str]:
|
||||||
|
"""Classify a failed conformance probe without confusing outages for gaps.
|
||||||
|
|
||||||
|
A provider transport failure means the probe did not establish either
|
||||||
|
support or non-support. Only a complete response that violates one of the
|
||||||
|
exposed tool contracts is evidence of a model structured-output limit.
|
||||||
|
"""
|
||||||
|
messages = [
|
||||||
|
str(failure.get("message") or "").casefold()
|
||||||
|
for report in reports
|
||||||
|
for failure in report.get("failures") or ()
|
||||||
|
if isinstance(failure, dict)
|
||||||
|
]
|
||||||
|
unavailable_markers = (
|
||||||
|
"transport unavailable",
|
||||||
|
"connection",
|
||||||
|
"network",
|
||||||
|
"timeout",
|
||||||
|
"timed out",
|
||||||
|
"temporarily unavailable",
|
||||||
|
)
|
||||||
|
if any(any(marker in message for marker in unavailable_markers) for message in messages):
|
||||||
|
return "MODEL_CAPABILITY_PROBE_UNAVAILABLE", "configuration_or_network"
|
||||||
|
return "MODEL_STRUCTURED_OUTPUT_UNSUPPORTED", "model_format_or_decision"
|
||||||
|
|
||||||
|
|
||||||
|
def _artifact_evidence_complete(
|
||||||
|
artifact_root: Path,
|
||||||
|
revisions: list[str],
|
||||||
|
active_revision: str,
|
||||||
|
artifact_manifest: dict[str, Any] | None,
|
||||||
|
) -> bool:
|
||||||
|
"""Require every published CAD decision to retain its reviewable evidence.
|
||||||
|
|
||||||
|
A non-empty report manifest is not enough: a task could otherwise report
|
||||||
|
only a rendered contract view while silently losing the STEP, topology, or
|
||||||
|
candidate review used to accept a revision. Candidate manifests protect
|
||||||
|
the immutable build inputs/outputs; the report manifest additionally
|
||||||
|
protects the frozen contract and final independent review written later.
|
||||||
|
"""
|
||||||
|
if not revisions or not active_revision:
|
||||||
|
return False
|
||||||
|
report_files = {
|
||||||
|
str(item.get("path") or "")
|
||||||
|
for item in (artifact_manifest or {}).get("files") or ()
|
||||||
|
if isinstance(item, dict)
|
||||||
|
}
|
||||||
|
required_report_files = {
|
||||||
|
"requirements-contract.json",
|
||||||
|
f"reviews/final/{active_revision}/final-review.json",
|
||||||
|
}
|
||||||
|
if artifact_manifest is not None and not required_report_files.issubset(report_files):
|
||||||
|
return False
|
||||||
|
if not all((artifact_root / path).is_file() for path in required_report_files):
|
||||||
|
return False
|
||||||
|
for revision_id in sorted(set(revisions)):
|
||||||
|
revision_root = artifact_root / "revisions" / revision_id
|
||||||
|
required_paths = {revision_root / relative for relative in _CANDIDATE_EVIDENCE_FILES}
|
||||||
|
manifest_path = revision_root / "manifest.json"
|
||||||
|
if not manifest_path.is_file() or not all(path.is_file() for path in required_paths):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
return False
|
||||||
|
declared = manifest.get("files") if isinstance(manifest, dict) else None
|
||||||
|
if not isinstance(declared, dict) or not _CANDIDATE_EVIDENCE_FILES.issubset(declared):
|
||||||
|
return False
|
||||||
|
for relative, digest in declared.items():
|
||||||
|
path = revision_root / str(relative)
|
||||||
|
if not isinstance(digest, str) or len(digest) != 64 or not path.is_file():
|
||||||
|
return False
|
||||||
|
if sha256(path.read_bytes()).hexdigest() != digest:
|
||||||
|
return False
|
||||||
|
if artifact_manifest is not None:
|
||||||
|
expected_report_paths = {f"revisions/{revision_id}/{relative}" for relative in _CANDIDATE_EVIDENCE_FILES}
|
||||||
|
if not expected_report_paths.issubset(report_files):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _failure_attribution(
|
||||||
|
*,
|
||||||
|
checks: dict[str, bool],
|
||||||
|
outcome: str,
|
||||||
|
events: list[dict[str, Any]],
|
||||||
|
projection: dict[str, Any],
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
"""Return one evidence-backed failure layer required by the test target."""
|
||||||
|
if outcome == "passed":
|
||||||
|
return None
|
||||||
|
codes: list[str] = []
|
||||||
|
for event in events:
|
||||||
|
payload = event.get("payload") if isinstance(event, dict) and isinstance(event.get("payload"), dict) else {}
|
||||||
|
result = payload.get("result") if isinstance(payload.get("result"), dict) else payload
|
||||||
|
code = result.get("code") if isinstance(result, dict) else ""
|
||||||
|
if isinstance(code, str) and code:
|
||||||
|
codes.append(code)
|
||||||
|
last_error = projection.get("last_error") if isinstance(projection, dict) else ""
|
||||||
|
if isinstance(last_error, str) and last_error:
|
||||||
|
codes.append(last_error)
|
||||||
|
for code in reversed(codes):
|
||||||
|
layer = _ERROR_FAILURE_LAYERS.get(code)
|
||||||
|
if layer:
|
||||||
|
return {
|
||||||
|
"layer": layer,
|
||||||
|
"reason_code": code,
|
||||||
|
"evidence_codes": list(dict.fromkeys(codes)),
|
||||||
|
"failed_checks": [name for name, passed in checks.items() if not passed],
|
||||||
|
}
|
||||||
|
failed_checks = [name for name, passed in checks.items() if not passed]
|
||||||
|
if outcome == "validation_capability_gap" or any(name in {"required_claims", "acceptance_contract_coverage"} for name in failed_checks):
|
||||||
|
layer, reason = "v3_contract_or_verifier", "VALIDATION_CAPABILITY_GAP"
|
||||||
|
elif any(name in {"deterministic_claims_pass", "required_operations", "required_operation_alternative"} for name in failed_checks):
|
||||||
|
layer, reason = "cdsl_expression", "CAD_ACCEPTANCE_GATE_FAILED"
|
||||||
|
elif any(name in {"token_budget", "call_budget", "author_turn_budget", "reviewer_turn_budget"} for name in failed_checks):
|
||||||
|
layer, reason = "configuration_or_network", "EVALUATION_BUDGET_EXCEEDED"
|
||||||
|
elif "raw_argument_audit" in failed_checks:
|
||||||
|
layer, reason = "v3_contract_or_verifier", "RAW_ARGUMENT_AUDIT_FAILED"
|
||||||
|
elif any(name in {"immutable_artifacts", "artifact_manifest", "required_artifact_evidence", "action_ledger"} for name in failed_checks):
|
||||||
|
layer, reason = "configuration_or_network", "ARTIFACT_EVIDENCE_INCOMPLETE"
|
||||||
|
else:
|
||||||
|
layer, reason = "model_format_or_decision", "WORKFLOW_TERMINAL_STATE_MISMATCH"
|
||||||
|
return {
|
||||||
|
"layer": layer,
|
||||||
|
"reason_code": reason,
|
||||||
|
"evidence_codes": list(dict.fromkeys(codes)),
|
||||||
|
"failed_checks": failed_checks,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _run_checks(
|
||||||
|
scenario: dict[str, Any],
|
||||||
|
projection: dict[str, Any],
|
||||||
|
usage: dict[str, Any],
|
||||||
|
requirements_contract: dict[str, Any] | None,
|
||||||
|
events: list[dict[str, Any]],
|
||||||
|
artifact_root: Path,
|
||||||
|
ledger: list[dict[str, Any]] | None = None,
|
||||||
|
tool_audits: list[dict[str, Any]] | None = None,
|
||||||
|
artifact_manifest: dict[str, Any] | None = None,
|
||||||
|
) -> dict[str, bool]:
|
||||||
|
records = usage.get("records") if isinstance(usage.get("records"), list) else []
|
||||||
|
author_calls = [item for item in records if isinstance(item, dict) and item.get("role") != "reviewer"]
|
||||||
|
reviewer_calls = [item for item in records if isinstance(item, dict) and item.get("role") == "reviewer"]
|
||||||
|
total_tokens = int(usage.get("prompt_tokens") or 0) + int(usage.get("completion_tokens") or 0)
|
||||||
|
audit_ledger = ledger if ledger is not None else [item for item in projection.get("action_ledger_summary") or () if isinstance(item, dict)]
|
||||||
|
accepted = [item for item in audit_ledger if isinstance(item, dict) and item.get("event") == "accepted"]
|
||||||
|
revisions = [str(item.get("revision_id") or "") for item in accepted]
|
||||||
|
operation_ids = {str(item.get("actual_atomic_id") or "") for item in accepted}
|
||||||
|
required_operation_groups = [
|
||||||
|
{str(atomic_id) for atomic_id in group if isinstance(atomic_id, str) and atomic_id}
|
||||||
|
for group in scenario.get("required_any_atomic_id_groups") or ()
|
||||||
|
if isinstance(group, list)
|
||||||
|
]
|
||||||
|
active_revision = str(projection.get("active_revision") or "")
|
||||||
|
manifest = artifact_root / "revisions" / active_revision / "manifest.json"
|
||||||
|
jsonl = artifact_root / "actions" / "action-ledger.jsonl"
|
||||||
|
final_claims = next((item.get("claim_results") for item in reversed(audit_ledger) if isinstance(item, dict) and item.get("event") == "completed" and isinstance(item.get("claim_results"), list)), [])
|
||||||
|
audit_records = tool_audits if isinstance(tool_audits, list) else []
|
||||||
|
audit_valid = bool(audit_records) and all(
|
||||||
|
isinstance(item, dict)
|
||||||
|
and isinstance(item.get("raw_arguments_hash"), str)
|
||||||
|
and len(item["raw_arguments_hash"]) == 64
|
||||||
|
and item.get("canonical_schema_valid") is True
|
||||||
|
and isinstance(item.get("state_binding"), dict)
|
||||||
|
and bool(item["state_binding"].get("phase"))
|
||||||
|
and item["state_binding"].get("binding_valid") is True
|
||||||
|
and item.get("single_allowed_call") is True
|
||||||
|
for item in audit_records
|
||||||
|
)
|
||||||
|
acceptance = _acceptance_coverage(scenario, requirements_contract)
|
||||||
|
artifact_evidence = _artifact_evidence_complete(
|
||||||
|
artifact_root,
|
||||||
|
revisions,
|
||||||
|
active_revision,
|
||||||
|
artifact_manifest,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"expected_terminal_phase": projection.get("phase") == scenario.get("expected_phase", "COMPLETED"),
|
||||||
|
"token_budget": total_tokens <= int(scenario["max_total_tokens"]),
|
||||||
|
"call_budget": len(records) <= int(scenario["max_total_calls"]),
|
||||||
|
"author_turn_budget": len(author_calls) <= int(scenario["max_author_turns"]),
|
||||||
|
"reviewer_turn_budget": len(reviewer_calls) <= int(scenario["max_reviewer_turns"]),
|
||||||
|
"required_claims": not acceptance["missing_claims"],
|
||||||
|
"acceptance_contract_coverage": acceptance["complete"],
|
||||||
|
"required_operations": {str(value) for value in scenario.get("required_atomic_ids") or ()}.issubset(operation_ids),
|
||||||
|
"required_operation_alternative": (
|
||||||
|
(not scenario.get("required_any_atomic_ids") or bool({str(value) for value in scenario["required_any_atomic_ids"]}.intersection(operation_ids)))
|
||||||
|
and all(group.intersection(operation_ids) for group in required_operation_groups)
|
||||||
|
),
|
||||||
|
"immutable_artifacts": bool(active_revision) and manifest.is_file(),
|
||||||
|
"required_artifact_evidence": artifact_evidence,
|
||||||
|
"artifact_manifest": (
|
||||||
|
artifact_manifest is None
|
||||||
|
or (
|
||||||
|
isinstance(artifact_manifest.get("files"), list)
|
||||||
|
and bool(artifact_manifest["files"])
|
||||||
|
and all(
|
||||||
|
isinstance(item, dict)
|
||||||
|
and isinstance(item.get("path"), str)
|
||||||
|
and isinstance(item.get("sha256"), str)
|
||||||
|
and len(item["sha256"]) == 64
|
||||||
|
for item in artifact_manifest["files"]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
"action_ledger": jsonl.is_file(),
|
||||||
|
"unique_revisions": len(revisions) == len(set(revisions)),
|
||||||
|
"deterministic_claims_pass": bool(final_claims) and all(
|
||||||
|
not isinstance(item, dict) or not item.get("deterministic") or item.get("status") == "pass"
|
||||||
|
for item in final_claims
|
||||||
|
),
|
||||||
|
"raw_argument_audit": audit_valid if tool_audits is not None else bool(records) and all(isinstance(item, dict) and isinstance(item.get("raw_arguments_hash"), str) and len(item["raw_arguments_hash"]) == 64 for item in records),
|
||||||
|
"no_schema_or_decision_rejections": not _rejection_codes(events),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _git_revision() -> str:
|
||||||
|
try:
|
||||||
|
completed = subprocess.run(
|
||||||
|
["git", "rev-parse", "HEAD"], cwd=BACKEND_ROOT.parent, capture_output=True,
|
||||||
|
text=True, check=True, timeout=5,
|
||||||
|
)
|
||||||
|
return completed.stdout.strip()
|
||||||
|
except (OSError, subprocess.SubprocessError):
|
||||||
|
return "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
def _event_audit(events: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
|
"""Persist event metadata without prompts, tool arguments, or tool facts."""
|
||||||
|
audit: list[dict[str, Any]] = []
|
||||||
|
for item in events:
|
||||||
|
payload = item.get("payload") if isinstance(item.get("payload"), dict) else {}
|
||||||
|
result = payload.get("result") if isinstance(payload.get("result"), dict) else {}
|
||||||
|
usage = payload.get("usage") if isinstance(payload.get("usage"), dict) else {}
|
||||||
|
audit.append({
|
||||||
|
"name": str(item.get("name") or ""), "tool": str(payload.get("tool") or ""),
|
||||||
|
"status": str(payload.get("status") or ""), "code": str(result.get("code") or payload.get("code") or ""),
|
||||||
|
"raw_arguments_hash": str(usage.get("raw_arguments_hash") or ""),
|
||||||
|
"field_errors": result.get("field_errors") if isinstance(result.get("field_errors"), list) else [],
|
||||||
|
})
|
||||||
|
return audit
|
||||||
|
|
||||||
|
|
||||||
|
def _report_tool_audits(audits: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
|
"""Return report-safe audit metadata without argument values or prompts."""
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"audit_id": item.get("audit_id"),
|
||||||
|
"at": item.get("at"),
|
||||||
|
"actor": item.get("actor"),
|
||||||
|
"tool": item.get("tool"),
|
||||||
|
"raw_arguments_hash": item.get("raw_arguments_hash"),
|
||||||
|
"canonical_schema_valid": item.get("canonical_schema_valid"),
|
||||||
|
"field_errors": item.get("field_errors") if isinstance(item.get("field_errors"), list) else [],
|
||||||
|
"state_binding": item.get("state_binding") if isinstance(item.get("state_binding"), dict) else {},
|
||||||
|
"returned_tool": item.get("returned_tool"),
|
||||||
|
"single_allowed_call": item.get("single_allowed_call"),
|
||||||
|
}
|
||||||
|
for item in audits
|
||||||
|
if isinstance(item, dict)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _ledger_identifiers(ledger: list[dict[str, Any]]) -> dict[str, list[str]]:
|
||||||
|
"""Expose audit identifiers explicitly without copying provider payloads."""
|
||||||
|
candidate_ids = sorted({
|
||||||
|
str(item.get("candidate_id"))
|
||||||
|
for item in ledger
|
||||||
|
if isinstance(item.get("candidate_id"), str) and item.get("candidate_id")
|
||||||
|
})
|
||||||
|
revision_ids = sorted({
|
||||||
|
str(item.get("revision_id"))
|
||||||
|
for item in ledger
|
||||||
|
if isinstance(item.get("revision_id"), str) and item.get("revision_id")
|
||||||
|
})
|
||||||
|
return {"candidate_ids": candidate_ids, "revision_ids": revision_ids}
|
||||||
|
|
||||||
|
|
||||||
|
def _final_claim_results(ledger: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
|
for item in reversed(ledger):
|
||||||
|
results = item.get("claim_results")
|
||||||
|
if item.get("event") == "completed" and isinstance(results, list):
|
||||||
|
return [value for value in results if isinstance(value, dict)]
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _redacted_correlation_ids(task_id: str, invocations: list[dict[str, Any]]) -> list[str]:
|
||||||
|
"""Keep run linkage without publishing task or provider correlation values."""
|
||||||
|
return [
|
||||||
|
canonical_hash({"task_id": task_id, "invocation_id": str(item.get("invocation_id") or "")})[:20]
|
||||||
|
for item in invocations
|
||||||
|
if isinstance(item, dict) and item.get("invocation_id")
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_artifact_manifest(artifact_root: Path) -> dict[str, Any]:
|
||||||
|
"""Hash reviewable CAD evidence without copying source or prompt content."""
|
||||||
|
allowed_exact = {"requirements-contract.json", "requirements-index.json", "requirements.md", "completion.md"}
|
||||||
|
allowed_prefixes = ("actions/", "revisions/", "reviews/", "documents/requirements-")
|
||||||
|
files: list[dict[str, str]] = []
|
||||||
|
if artifact_root.is_dir():
|
||||||
|
for path in sorted(artifact_root.rglob("*")):
|
||||||
|
if not path.is_file():
|
||||||
|
continue
|
||||||
|
relative = path.relative_to(artifact_root).as_posix()
|
||||||
|
if relative not in allowed_exact and not relative.startswith(allowed_prefixes):
|
||||||
|
continue
|
||||||
|
files.append({"path": relative, "sha256": sha256(path.read_bytes()).hexdigest()})
|
||||||
|
return {"schema_version": "cad.live-eval-artifact-manifest.v1", "artifact_root": str(artifact_root), "files": files}
|
||||||
|
|
||||||
|
|
||||||
|
async def _run(arguments: argparse.Namespace, report_root: Path) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
scenarios = _fixture(arguments.suite, arguments.scenario)
|
||||||
|
except ValueError as error:
|
||||||
|
return {"status": "LIVE_EVAL_BLOCKED", "error": str(error)}
|
||||||
|
settings = get_settings()
|
||||||
|
try:
|
||||||
|
author_provider, author_model = settings.resolve_model(arguments.author_provider, arguments.author_model)
|
||||||
|
if arguments.review_provider or arguments.review_model:
|
||||||
|
review_provider, review_model = settings.resolve_model(arguments.review_provider, arguments.review_model)
|
||||||
|
if review_provider.id == author_provider.id and review_model.id == author_model.id:
|
||||||
|
raise ValueError("Reviewer must differ from author")
|
||||||
|
else:
|
||||||
|
review_provider, review_model = settings.resolve_independent_review_model(author_provider, author_model)
|
||||||
|
except ValueError as error:
|
||||||
|
return {"status": "LIVE_EVAL_BLOCKED", "error": str(error)}
|
||||||
|
baseline_path = arguments.baseline_report.resolve() if arguments.baseline_report else None
|
||||||
|
if arguments.suite == "release" and baseline_path is None:
|
||||||
|
return {"status": "LIVE_EVAL_BLOCKED", "error": "TOKEN_BASELINE_REQUIRED: release requires a provenance-checked pre-v3 token baseline report."}
|
||||||
|
try:
|
||||||
|
baseline = load_token_baseline(baseline_path) if baseline_path else None
|
||||||
|
current_author_identity = author_request_identity(author_provider, author_model)
|
||||||
|
runtime_profile_hash = profile_sha256(settings.engine_root / "profile_schema.json")
|
||||||
|
except TokenBaselineError as error:
|
||||||
|
return {"status": "LIVE_EVAL_BLOCKED", "error": f"TOKEN_BASELINE_INVALID: {error}"}
|
||||||
|
if baseline is not None:
|
||||||
|
baseline_errors = validate_token_baseline_provenance(
|
||||||
|
baseline,
|
||||||
|
scenarios=scenarios,
|
||||||
|
author_identity=current_author_identity,
|
||||||
|
runtime_profile_hash=runtime_profile_hash,
|
||||||
|
)
|
||||||
|
if baseline_errors:
|
||||||
|
return {"status": "LIVE_EVAL_BLOCKED", "error": f"TOKEN_BASELINE_INVALID: {baseline_errors[0]}", "baseline_errors": baseline_errors}
|
||||||
|
isolated = replace(settings, task_root=report_root / "artifacts", conversation_root=report_root / "conversations")
|
||||||
|
services = compose_v3(isolated)
|
||||||
|
contracts = [
|
||||||
|
{
|
||||||
|
"atomic_id": atomic_id, "contract_hash": services.workflow.runtime.operation_contract(atomic_id)["contract_hash"],
|
||||||
|
"contract_version": services.workflow.runtime.operation_contract(atomic_id)["contract_version"],
|
||||||
|
"registry_revision": services.workflow.runtime.operation_contract(atomic_id)["registry_revision"],
|
||||||
|
}
|
||||||
|
for atomic_id in services.workflow.runtime.supported_atomic_ids()
|
||||||
|
]
|
||||||
|
verifier_schema_hash = canonical_hash(default_registry().expected_one_of_schema())
|
||||||
|
try:
|
||||||
|
author_capability = await verify_model_capability(services.repository, services.workflow.runtime, services.models, provider_id=author_provider.id, model_id=author_model.id, force=True)
|
||||||
|
reviewer_capability = await verify_model_capability(services.repository, services.workflow.runtime, services.models, provider_id=review_provider.id, model_id=review_model.id, force=True)
|
||||||
|
except Exception as error:
|
||||||
|
return {"status": "LIVE_EVAL_BLOCKED", "error": str(error)[:1000]}
|
||||||
|
if not author_capability.get("supported") or not reviewer_capability.get("supported"):
|
||||||
|
error, failure_layer = _capability_block_reason(author_capability, reviewer_capability)
|
||||||
|
return {
|
||||||
|
"status": "LIVE_EVAL_BLOCKED",
|
||||||
|
"error": error,
|
||||||
|
"failure_layer": failure_layer,
|
||||||
|
"author_capability": author_capability,
|
||||||
|
"reviewer_capability": reviewer_capability,
|
||||||
|
}
|
||||||
|
results: list[dict[str, Any]] = []
|
||||||
|
for scenario in scenarios:
|
||||||
|
for repetition in range(1, 4 if arguments.suite == "release" else 2):
|
||||||
|
services.workflow.config = replace(
|
||||||
|
services.workflow.config,
|
||||||
|
# State transitions include local candidate recovery, so the
|
||||||
|
# loop guard is deliberately independent from the externally
|
||||||
|
# measured model-call budgets below.
|
||||||
|
max_turns=max(8, int(scenario["max_total_calls"]) * 3),
|
||||||
|
max_author_turns=int(scenario["max_author_turns"]),
|
||||||
|
max_reviewer_turns=int(scenario["max_reviewer_turns"]),
|
||||||
|
max_model_calls=int(scenario["max_total_calls"]),
|
||||||
|
)
|
||||||
|
task_id = f"cad_{secrets.token_hex(6)}"
|
||||||
|
oracle_claims = [item for item in scenario.get("required_claims") or () if isinstance(item, dict)]
|
||||||
|
if oracle_claims:
|
||||||
|
services.workflow.requirements.register_evaluation_contract_oracle(
|
||||||
|
task_id,
|
||||||
|
oracle_claims,
|
||||||
|
validation_capability_gaps=[item for item in scenario.get("validation_capability_gaps") or () if isinstance(item, dict)],
|
||||||
|
)
|
||||||
|
services.workflow.create_task(task_id, str(scenario["request"]))
|
||||||
|
events: list[dict[str, Any]] = []
|
||||||
|
started = datetime.now(timezone.utc)
|
||||||
|
try:
|
||||||
|
async with asyncio.timeout(int(scenario["max_wall_seconds"])):
|
||||||
|
async for name, payload in services.workflow.run(
|
||||||
|
task_id=task_id,
|
||||||
|
author=ModelIdentity(author_provider.id, author_model.id),
|
||||||
|
reviewer=ModelIdentity(review_provider.id, review_model.id),
|
||||||
|
):
|
||||||
|
events.append({"name": name, "payload": payload})
|
||||||
|
except TimeoutError:
|
||||||
|
events.append({"name": "timeout", "payload": {"code": "LIVE_EVAL_TIMEOUT"}})
|
||||||
|
projection = services.repository.get_task_projection(task_id) or {}
|
||||||
|
usage = services.repository.usage_summary(task_id)
|
||||||
|
ledger = services.repository.ledger_events(task_id)
|
||||||
|
invocations = services.repository.invocation_records(task_id)
|
||||||
|
tool_audits = services.repository.tool_audits(task_id)
|
||||||
|
terminal = next((item["payload"] for item in reversed(events) if item["name"] == "task_terminal"), {})
|
||||||
|
artifact_root = (isolated.task_root / task_id).resolve()
|
||||||
|
artifact_manifest = _safe_artifact_manifest(artifact_root)
|
||||||
|
finished = datetime.now(timezone.utc)
|
||||||
|
checks = _run_checks(
|
||||||
|
scenario,
|
||||||
|
projection,
|
||||||
|
usage,
|
||||||
|
services.artifacts.read_requirements_contract(
|
||||||
|
task_id,
|
||||||
|
services.repository.get_state(task_id).requirements_contract_path
|
||||||
|
if services.repository.get_state(task_id) is not None
|
||||||
|
else "",
|
||||||
|
),
|
||||||
|
events,
|
||||||
|
artifact_root,
|
||||||
|
ledger,
|
||||||
|
tool_audits,
|
||||||
|
artifact_manifest,
|
||||||
|
)
|
||||||
|
success = all(checks.values())
|
||||||
|
acceptance = _acceptance_coverage(
|
||||||
|
scenario,
|
||||||
|
services.artifacts.read_requirements_contract(
|
||||||
|
task_id,
|
||||||
|
services.repository.get_state(task_id).requirements_contract_path
|
||||||
|
if services.repository.get_state(task_id) is not None
|
||||||
|
else "",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
outcome = (
|
||||||
|
"passed"
|
||||||
|
if success
|
||||||
|
else "validation_capability_gap"
|
||||||
|
if not acceptance["complete"]
|
||||||
|
and all(value for key, value in checks.items() if key != "acceptance_contract_coverage")
|
||||||
|
else "failed"
|
||||||
|
)
|
||||||
|
failure_attribution = _failure_attribution(
|
||||||
|
checks=checks,
|
||||||
|
outcome=outcome,
|
||||||
|
events=events,
|
||||||
|
projection=projection,
|
||||||
|
)
|
||||||
|
results.append({
|
||||||
|
"scenario": scenario["id"], "repetition": repetition, "success": success, "outcome": outcome,
|
||||||
|
"started_at": started.isoformat(), "finished_at": finished.isoformat(),
|
||||||
|
"duration_ms": round((finished - started).total_seconds() * 1000),
|
||||||
|
"terminal": terminal, "projection": projection, "usage": usage, "checks": checks,
|
||||||
|
"acceptance_coverage": acceptance,
|
||||||
|
"failure_attribution": failure_attribution,
|
||||||
|
"rejection_codes": _rejection_codes(events),
|
||||||
|
"schema_rejection_count": len(_rejection_codes(events)),
|
||||||
|
"retry_count": sum(1 for event in events if (event.get("payload") or {}).get("status") == "error"),
|
||||||
|
"rollback_count": sum(1 for item in ledger if item.get("event") == "rollback"),
|
||||||
|
**_ledger_identifiers(ledger),
|
||||||
|
"final_claim_results": _final_claim_results(ledger),
|
||||||
|
"redacted_correlation_ids": _redacted_correlation_ids(task_id, invocations),
|
||||||
|
"tool_audits": _report_tool_audits(tool_audits),
|
||||||
|
"artifact_root": str(artifact_root), "action_ledger_path": str(artifact_root / "actions" / "action-ledger.jsonl"),
|
||||||
|
"artifact_manifest": artifact_manifest,
|
||||||
|
"ledger": ledger, "invocations": invocations, "event_audit": _event_audit(events),
|
||||||
|
})
|
||||||
|
token_comparison = (
|
||||||
|
compare_token_baseline(
|
||||||
|
baseline,
|
||||||
|
scenarios=scenarios,
|
||||||
|
v3_results=results,
|
||||||
|
author_identity=current_author_identity,
|
||||||
|
runtime_profile_hash=runtime_profile_hash,
|
||||||
|
)
|
||||||
|
if baseline is not None
|
||||||
|
else {"schema_version": "cad.token-comparison.v1", "status": "not_required"}
|
||||||
|
)
|
||||||
|
token_gate = all(token_comparison.get("checks", {}).values()) if baseline is not None else True
|
||||||
|
return {
|
||||||
|
"status": "passed" if results and all(item["success"] for item in results) and token_gate else "failed",
|
||||||
|
"author": {"provider": author_provider.id, "model": author_model.id},
|
||||||
|
"author_request_identity": current_author_identity,
|
||||||
|
"reviewer": {"provider": review_provider.id, "model": review_model.id},
|
||||||
|
"author_capability": author_capability,
|
||||||
|
"reviewer_capability": reviewer_capability,
|
||||||
|
"structured_output_mode": {
|
||||||
|
"author": str(author_capability.get("mode") or ""),
|
||||||
|
"reviewer": str(reviewer_capability.get("mode") or ""),
|
||||||
|
},
|
||||||
|
"git_revision": _git_revision(),
|
||||||
|
"protocol_version": "3.0",
|
||||||
|
"runtime_profile_sha256": runtime_profile_hash,
|
||||||
|
"operation_contracts": contracts,
|
||||||
|
"verifier_registry_version": "cad.verifier-registry.v1",
|
||||||
|
"verifier_schema_hash": verifier_schema_hash,
|
||||||
|
"token_comparison": token_comparison,
|
||||||
|
"baseline_report": str(baseline_path) if baseline_path else "",
|
||||||
|
"results": results,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
arguments = _arguments()
|
||||||
|
report_root = BACKEND_ROOT / "live-evals" / datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
||||||
|
report_root.mkdir(parents=True, exist_ok=True)
|
||||||
|
try:
|
||||||
|
result = asyncio.run(_run(arguments, report_root))
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
# Let an explicit operator interruption retain its normal CLI
|
||||||
|
# semantics. An external kill cannot be reported reliably either.
|
||||||
|
raise
|
||||||
|
except BaseException as error:
|
||||||
|
# A live evaluation may fail before it creates a task (for example
|
||||||
|
# during conformance). Its report is still the release gate's audit
|
||||||
|
# artifact, so an unexpected evaluator failure must not disappear.
|
||||||
|
result = {
|
||||||
|
"status": "LIVE_EVAL_BLOCKED",
|
||||||
|
"error": f"UNEXPECTED_LIVE_EVAL_ERROR: {type(error).__name__}: {str(error)[:900]}",
|
||||||
|
"failure_layer": "configuration_or_network",
|
||||||
|
}
|
||||||
|
if result["status"] == "LIVE_EVAL_BLOCKED" and arguments.allow_skip and not arguments.require_live:
|
||||||
|
result["skip_reason"] = result.get("error", "live provider access is unavailable")
|
||||||
|
result["status"] = "skipped"
|
||||||
|
result.update({"suite": arguments.suite, "require_live": arguments.require_live, "report_root": str(report_root.resolve())})
|
||||||
|
(report_root / "report.json").write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||||
|
print(json.dumps({"status": result["status"], "report": str((report_root / "report.json").resolve())}, ensure_ascii=False))
|
||||||
|
if result["status"] == "passed":
|
||||||
|
return 0
|
||||||
|
if result["status"] == "skipped":
|
||||||
|
return 0
|
||||||
|
return 2
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,297 @@
|
|||||||
|
"""Provenance-checked token baseline comparison for the v3 release gate.
|
||||||
|
|
||||||
|
The target intentionally does not accept estimated tokens or a hand-written
|
||||||
|
percentage. A v2 run must record the same fixed requests and author request
|
||||||
|
configuration before it can be compared with a v3 release report.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from hashlib import sha256
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from statistics import median
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.settings import ProviderConfig, ProviderModel
|
||||||
|
|
||||||
|
|
||||||
|
BASELINE_SCHEMA_VERSION = "cad.token-baseline.v1"
|
||||||
|
BASELINE_PROTOCOL_VERSION = "2.0"
|
||||||
|
MINIMUM_REPETITIONS = 3
|
||||||
|
TOKEN_REDUCTION_TARGET = 0.30
|
||||||
|
MEASUREMENTS = {"prompt_tokens", "context_chars"}
|
||||||
|
|
||||||
|
|
||||||
|
class TokenBaselineError(ValueError):
|
||||||
|
"""A baseline cannot prove the release token target."""
|
||||||
|
|
||||||
|
|
||||||
|
def request_sha256(request: str) -> str:
|
||||||
|
return sha256(request.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def profile_sha256(profile_path: Path) -> str:
|
||||||
|
try:
|
||||||
|
return sha256(profile_path.read_bytes()).hexdigest()
|
||||||
|
except OSError as error:
|
||||||
|
raise TokenBaselineError("Runtime profile is unavailable for token-baseline provenance.") from error
|
||||||
|
|
||||||
|
|
||||||
|
def author_request_identity(provider: ProviderConfig, model: ProviderModel) -> dict[str, Any]:
|
||||||
|
"""Capture every author setting that can affect prompt-token comparison."""
|
||||||
|
return {
|
||||||
|
"provider": provider.id,
|
||||||
|
"model": model.id,
|
||||||
|
"api_style": provider.api_style,
|
||||||
|
"reasoning_effort": provider.reasoning_effort,
|
||||||
|
# Chat Completions is explicitly deterministic in StructuredModelGateway.
|
||||||
|
# Responses models use their configured/provider-default sampling mode.
|
||||||
|
"sampling": {"temperature": 0 if provider.api_style == "chat_completions" else None},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def load_token_baseline(path: Path) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, json.JSONDecodeError) as error:
|
||||||
|
raise TokenBaselineError("Token baseline report is missing or is not valid JSON.") from error
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
raise TokenBaselineError("Token baseline report must be a JSON object.")
|
||||||
|
if payload.get("schema_version") != BASELINE_SCHEMA_VERSION:
|
||||||
|
raise TokenBaselineError(f"Token baseline must use {BASELINE_SCHEMA_VERSION}.")
|
||||||
|
if payload.get("protocol_version") != BASELINE_PROTOCOL_VERSION:
|
||||||
|
raise TokenBaselineError("Token baseline must be a measured pre-v3 (2.0) report.")
|
||||||
|
if payload.get("measurement") not in MEASUREMENTS:
|
||||||
|
raise TokenBaselineError("Token baseline measurement must be prompt_tokens or context_chars.")
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def validate_token_baseline_provenance(
|
||||||
|
baseline: dict[str, Any],
|
||||||
|
*,
|
||||||
|
scenarios: list[dict[str, Any]],
|
||||||
|
author_identity: dict[str, Any],
|
||||||
|
runtime_profile_hash: str,
|
||||||
|
) -> list[str]:
|
||||||
|
"""Reject an incomparable baseline before any billable v3 API call."""
|
||||||
|
errors: list[str] = []
|
||||||
|
if baseline.get("schema_version") != BASELINE_SCHEMA_VERSION:
|
||||||
|
errors.append(f"schema_version must equal {BASELINE_SCHEMA_VERSION}")
|
||||||
|
if baseline.get("protocol_version") != BASELINE_PROTOCOL_VERSION:
|
||||||
|
errors.append("protocol_version must equal 2.0")
|
||||||
|
if baseline.get("author") != author_identity:
|
||||||
|
errors.append("author provider/model/API mode/reasoning/sampling does not match the v3 release")
|
||||||
|
if baseline.get("runtime_profile_sha256") != runtime_profile_hash:
|
||||||
|
errors.append("runtime profile hash does not match the v3 release")
|
||||||
|
measurement = str(baseline.get("measurement") or "")
|
||||||
|
if measurement not in MEASUREMENTS:
|
||||||
|
errors.append("measurement must be prompt_tokens or context_chars")
|
||||||
|
baseline_scenarios = baseline.get("scenarios")
|
||||||
|
if not isinstance(baseline_scenarios, list):
|
||||||
|
return [*errors, "scenarios must be an array"]
|
||||||
|
by_scenario = {
|
||||||
|
str(item.get("scenario") or ""): item
|
||||||
|
for item in baseline_scenarios
|
||||||
|
if isinstance(item, dict) and item.get("scenario")
|
||||||
|
}
|
||||||
|
for scenario in scenarios:
|
||||||
|
scenario_id = str(scenario.get("id") or "")
|
||||||
|
expected_request_hash = request_sha256(str(scenario.get("request") or ""))
|
||||||
|
baseline_scenario = by_scenario.get(scenario_id)
|
||||||
|
if not isinstance(baseline_scenario, dict):
|
||||||
|
errors.append(f"missing baseline scenario {scenario_id}")
|
||||||
|
continue
|
||||||
|
if baseline_scenario.get("request_sha256") != expected_request_hash:
|
||||||
|
errors.append(f"baseline request hash differs for {scenario_id}")
|
||||||
|
repetitions = baseline_scenario.get("repetitions")
|
||||||
|
if not isinstance(repetitions, list):
|
||||||
|
errors.append(f"baseline {scenario_id} repetitions must be an array")
|
||||||
|
continue
|
||||||
|
by_repetition = {
|
||||||
|
int(item.get("repetition")): item
|
||||||
|
for item in repetitions
|
||||||
|
if isinstance(item, dict) and isinstance(item.get("repetition"), int)
|
||||||
|
}
|
||||||
|
expected_repetitions = set(range(1, MINIMUM_REPETITIONS + 1))
|
||||||
|
if not expected_repetitions.issubset(by_repetition):
|
||||||
|
errors.append(f"baseline {scenario_id} is missing repetitions 1..{MINIMUM_REPETITIONS}")
|
||||||
|
continue
|
||||||
|
for repetition in expected_repetitions:
|
||||||
|
run = by_repetition[repetition]
|
||||||
|
if _non_negative_int(run.get("author_metric")) is None or _non_negative_int(run.get("plan_review_metric")) is None:
|
||||||
|
errors.append(f"baseline {scenario_id} repetition {repetition} has invalid {measurement} metrics")
|
||||||
|
for field in ("completed", "final_review_passed", "deterministic_claims_pass"):
|
||||||
|
if not isinstance(run.get(field), bool):
|
||||||
|
errors.append(f"baseline {scenario_id} repetition {repetition} has non-boolean {field}")
|
||||||
|
return errors
|
||||||
|
|
||||||
|
|
||||||
|
def compare_token_baseline(
|
||||||
|
baseline: dict[str, Any],
|
||||||
|
*,
|
||||||
|
scenarios: list[dict[str, Any]],
|
||||||
|
v3_results: list[dict[str, Any]],
|
||||||
|
author_identity: dict[str, Any],
|
||||||
|
runtime_profile_hash: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Return a complete, fail-closed comparison for the release report."""
|
||||||
|
errors = validate_token_baseline_provenance(
|
||||||
|
baseline, scenarios=scenarios, author_identity=author_identity,
|
||||||
|
runtime_profile_hash=runtime_profile_hash,
|
||||||
|
)
|
||||||
|
measurement = str(baseline.get("measurement") or "")
|
||||||
|
|
||||||
|
baseline_scenarios = baseline.get("scenarios")
|
||||||
|
if not isinstance(baseline_scenarios, list):
|
||||||
|
errors.append("scenarios must be an array")
|
||||||
|
baseline_scenarios = []
|
||||||
|
by_scenario = {
|
||||||
|
str(item.get("scenario") or ""): item
|
||||||
|
for item in baseline_scenarios
|
||||||
|
if isinstance(item, dict) and item.get("scenario")
|
||||||
|
}
|
||||||
|
current_by_scenario: dict[str, list[dict[str, Any]]] = {}
|
||||||
|
for result in v3_results:
|
||||||
|
if isinstance(result, dict) and isinstance(result.get("scenario"), str):
|
||||||
|
current_by_scenario.setdefault(result["scenario"], []).append(result)
|
||||||
|
|
||||||
|
baseline_tokens: list[int] = []
|
||||||
|
current_tokens: list[int] = []
|
||||||
|
baseline_completed: list[bool] = []
|
||||||
|
current_completed: list[bool] = []
|
||||||
|
baseline_final_review: list[bool] = []
|
||||||
|
current_final_review: list[bool] = []
|
||||||
|
baseline_deterministic: list[bool] = []
|
||||||
|
current_deterministic: list[bool] = []
|
||||||
|
per_scenario: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
for scenario in scenarios:
|
||||||
|
scenario_id = str(scenario.get("id") or "")
|
||||||
|
expected_request_hash = request_sha256(str(scenario.get("request") or ""))
|
||||||
|
baseline_scenario = by_scenario.get(scenario_id)
|
||||||
|
if not isinstance(baseline_scenario, dict):
|
||||||
|
errors.append(f"missing baseline scenario {scenario_id}")
|
||||||
|
continue
|
||||||
|
if baseline_scenario.get("request_sha256") != expected_request_hash:
|
||||||
|
errors.append(f"baseline request hash differs for {scenario_id}")
|
||||||
|
continue
|
||||||
|
repetitions = baseline_scenario.get("repetitions")
|
||||||
|
if not isinstance(repetitions, list) or len(repetitions) < MINIMUM_REPETITIONS:
|
||||||
|
errors.append(f"baseline {scenario_id} needs at least {MINIMUM_REPETITIONS} repetitions")
|
||||||
|
continue
|
||||||
|
current = current_by_scenario.get(scenario_id, [])
|
||||||
|
if len(current) < MINIMUM_REPETITIONS:
|
||||||
|
errors.append(f"v3 {scenario_id} needs at least {MINIMUM_REPETITIONS} repetitions")
|
||||||
|
continue
|
||||||
|
baseline_by_repetition = {
|
||||||
|
int(item.get("repetition")) : item
|
||||||
|
for item in repetitions
|
||||||
|
if isinstance(item, dict) and isinstance(item.get("repetition"), int)
|
||||||
|
}
|
||||||
|
current_by_repetition = {
|
||||||
|
int(item.get("repetition")): item
|
||||||
|
for item in current
|
||||||
|
if isinstance(item.get("repetition"), int)
|
||||||
|
}
|
||||||
|
expected_repetitions = set(range(1, MINIMUM_REPETITIONS + 1))
|
||||||
|
if not expected_repetitions.issubset(baseline_by_repetition):
|
||||||
|
errors.append(f"baseline {scenario_id} is missing repetitions 1..{MINIMUM_REPETITIONS}")
|
||||||
|
continue
|
||||||
|
if not expected_repetitions.issubset(current_by_repetition):
|
||||||
|
errors.append(f"v3 {scenario_id} is missing repetitions 1..{MINIMUM_REPETITIONS}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
scenario_baseline_tokens: list[int] = []
|
||||||
|
scenario_current_tokens: list[int] = []
|
||||||
|
for repetition in sorted(expected_repetitions):
|
||||||
|
baseline_run = baseline_by_repetition[repetition]
|
||||||
|
author_tokens = _non_negative_int(baseline_run.get("author_metric"))
|
||||||
|
plan_review_tokens = _non_negative_int(baseline_run.get("plan_review_metric"))
|
||||||
|
if author_tokens is None or plan_review_tokens is None:
|
||||||
|
errors.append(f"baseline {scenario_id} repetition {repetition} has invalid {measurement} metrics")
|
||||||
|
continue
|
||||||
|
current_run = current_by_repetition[repetition]
|
||||||
|
usage = current_run.get("usage") if isinstance(current_run.get("usage"), dict) else {}
|
||||||
|
v3_author_tokens = _v3_author_metric(usage, measurement)
|
||||||
|
if v3_author_tokens is None:
|
||||||
|
errors.append(f"v3 {scenario_id} repetition {repetition} has invalid author {measurement} usage")
|
||||||
|
continue
|
||||||
|
scenario_baseline_tokens.append(author_tokens + plan_review_tokens)
|
||||||
|
scenario_current_tokens.append(v3_author_tokens)
|
||||||
|
baseline_completed.append(bool(baseline_run.get("completed")))
|
||||||
|
current_completed.append(str((current_run.get("projection") or {}).get("phase") or "") == "COMPLETED")
|
||||||
|
baseline_final_review.append(bool(baseline_run.get("final_review_passed")))
|
||||||
|
current_final_review.append(str((current_run.get("terminal") or {}).get("lifecycle") or "") == "completed")
|
||||||
|
baseline_deterministic.append(bool(baseline_run.get("deterministic_claims_pass")))
|
||||||
|
checks = current_run.get("checks") if isinstance(current_run.get("checks"), dict) else {}
|
||||||
|
current_deterministic.append(bool(checks.get("deterministic_claims_pass")))
|
||||||
|
if len(scenario_baseline_tokens) == MINIMUM_REPETITIONS and len(scenario_current_tokens) == MINIMUM_REPETITIONS:
|
||||||
|
baseline_tokens.extend(scenario_baseline_tokens)
|
||||||
|
current_tokens.extend(scenario_current_tokens)
|
||||||
|
per_scenario.append({
|
||||||
|
"scenario": scenario_id,
|
||||||
|
"baseline_median_metric": median(scenario_baseline_tokens),
|
||||||
|
"v3_median_author_metric": median(scenario_current_tokens),
|
||||||
|
})
|
||||||
|
|
||||||
|
baseline_median = median(baseline_tokens) if baseline_tokens else None
|
||||||
|
current_median = median(current_tokens) if current_tokens else None
|
||||||
|
reduction = (1 - current_median / baseline_median) if baseline_median and current_median is not None else None
|
||||||
|
baseline_valid = not errors
|
||||||
|
checks = {
|
||||||
|
"baseline_valid": baseline_valid,
|
||||||
|
"median_metric_reduction": baseline_valid and reduction is not None and reduction >= TOKEN_REDUCTION_TARGET,
|
||||||
|
"completion_rate_not_lower": baseline_valid and _rate(current_completed) >= _rate(baseline_completed) if baseline_completed and current_completed else False,
|
||||||
|
"final_review_rate_not_lower": baseline_valid and _rate(current_final_review) >= _rate(baseline_final_review) if baseline_final_review and current_final_review else False,
|
||||||
|
"deterministic_claim_rate_not_lower": baseline_valid and _rate(current_deterministic) >= _rate(baseline_deterministic) if baseline_deterministic and current_deterministic else False,
|
||||||
|
"plan_review_calls_removed": baseline_valid and all(
|
||||||
|
not str(record.get("tool") or "").startswith("modeling_plan")
|
||||||
|
for result in v3_results if isinstance(result, dict)
|
||||||
|
for record in ((result.get("usage") or {}).get("records") or [])
|
||||||
|
if isinstance(record, dict)
|
||||||
|
),
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"schema_version": "cad.token-comparison.v1",
|
||||||
|
"baseline_protocol_version": baseline.get("protocol_version"),
|
||||||
|
"measurement": measurement,
|
||||||
|
"baseline_median_metric": baseline_median,
|
||||||
|
"v3_median_author_metric": current_median,
|
||||||
|
"median_metric_reduction": reduction,
|
||||||
|
"target_median_metric_reduction": TOKEN_REDUCTION_TARGET,
|
||||||
|
"baseline_completion_rate": _rate(baseline_completed),
|
||||||
|
"v3_completion_rate": _rate(current_completed),
|
||||||
|
"baseline_final_review_rate": _rate(baseline_final_review),
|
||||||
|
"v3_final_review_rate": _rate(current_final_review),
|
||||||
|
"baseline_deterministic_claim_rate": _rate(baseline_deterministic),
|
||||||
|
"v3_deterministic_claim_rate": _rate(current_deterministic),
|
||||||
|
"per_scenario": per_scenario,
|
||||||
|
"errors": errors,
|
||||||
|
"checks": checks,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _non_negative_int(value: Any) -> int | None:
|
||||||
|
return value if isinstance(value, int) and value >= 0 else None
|
||||||
|
|
||||||
|
|
||||||
|
def _v3_author_metric(usage: dict[str, Any], measurement: str) -> int | None:
|
||||||
|
records = usage.get("records")
|
||||||
|
if not isinstance(records, list):
|
||||||
|
return None
|
||||||
|
author_records = [item for item in records if isinstance(item, dict) and item.get("role") != "reviewer"]
|
||||||
|
if not author_records:
|
||||||
|
return None
|
||||||
|
if measurement == "prompt_tokens" and not all(item.get("usage_available") is True for item in author_records):
|
||||||
|
return None
|
||||||
|
field = "prompt_tokens" if measurement == "prompt_tokens" else "context_chars"
|
||||||
|
values = [
|
||||||
|
_non_negative_int(item.get(field))
|
||||||
|
for item in author_records
|
||||||
|
]
|
||||||
|
return sum(value for value in values if value is not None) if all(value is not None for value in values) else None
|
||||||
|
|
||||||
|
|
||||||
|
def _rate(values: list[bool]) -> float | None:
|
||||||
|
return sum(values) / len(values) if values else None
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
"""Ports owned by application code. Implementations live only in adapters."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any, Protocol
|
||||||
|
|
||||||
|
from .domain.state import TaskState
|
||||||
|
|
||||||
|
|
||||||
|
class AdapterUnavailable(RuntimeError):
|
||||||
|
"""A bounded external-service outage; handlers must preserve checkpoints."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class InvocationRecord:
|
||||||
|
invocation_id: str
|
||||||
|
idempotency_key: str
|
||||||
|
status: str
|
||||||
|
result: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class CandidateStage:
|
||||||
|
stage_id: str
|
||||||
|
output_dir: str
|
||||||
|
|
||||||
|
|
||||||
|
class UnitOfWork(Protocol):
|
||||||
|
def commit(self) -> None: ...
|
||||||
|
def rollback(self) -> None: ...
|
||||||
|
|
||||||
|
|
||||||
|
class TaskRepository(Protocol):
|
||||||
|
def create_task(self, task_id: str, request: str) -> TaskState: ...
|
||||||
|
def get_state(self, task_id: str) -> TaskState | None: ...
|
||||||
|
def get_task_projection(self, task_id: str) -> dict[str, Any] | None: ...
|
||||||
|
def ledger_events(self, task_id: str) -> list[dict[str, Any]]: ...
|
||||||
|
def invocation_records(self, task_id: str) -> list[dict[str, Any]]: ...
|
||||||
|
def compare_and_swap(
|
||||||
|
self,
|
||||||
|
state: TaskState,
|
||||||
|
*,
|
||||||
|
events: list[dict[str, Any]] = (),
|
||||||
|
invocation_id: str | None = None,
|
||||||
|
invocation_result: dict[str, Any] | None = None,
|
||||||
|
) -> bool: ...
|
||||||
|
def get_invocation(self, task_id: str, invocation_id: str) -> InvocationRecord | None: ...
|
||||||
|
def begin_invocation(self, task_id: str, invocation_id: str, idempotency_key: str) -> InvocationRecord: ...
|
||||||
|
def finish_invocation(self, invocation_id: str, result: dict[str, Any]) -> None: ...
|
||||||
|
def append_outbox(self, task_id: str, event: dict[str, Any]) -> None: ...
|
||||||
|
def pending_outbox(self, limit: int = 100, *, task_id: str | None = None) -> list[dict[str, Any]]: ...
|
||||||
|
def mark_outbox_published(self, event_id: int) -> None: ...
|
||||||
|
def record_usage(self, task_id: str, payload: dict[str, Any]) -> None: ...
|
||||||
|
def record_tool_audit(self, task_id: str, payload: dict[str, Any]) -> None: ...
|
||||||
|
def tool_audits(self, task_id: str) -> list[dict[str, Any]]: ...
|
||||||
|
def running_task_ids(self) -> list[str]: ...
|
||||||
|
|
||||||
|
|
||||||
|
class ArtifactStore(Protocol):
|
||||||
|
def initialize_task(self, task_id: str, request: str, *, source_blocks: list[dict[str, Any]] | None = None) -> None: ...
|
||||||
|
def sync_action_ledger(self, task_id: str, events: list[dict[str, Any]]) -> str: ...
|
||||||
|
def write_source_index(self, task_id: str, request: str) -> dict[str, str]: ...
|
||||||
|
def read_source_index(self, task_id: str) -> dict[str, str]: ...
|
||||||
|
def read_source_requirements(self, task_id: str) -> str: ...
|
||||||
|
def read_requirements_draft(self, task_id: str, artifact_path: str = "") -> dict[str, Any]: ...
|
||||||
|
def write_requirements_draft(self, task_id: str, payload: dict[str, Any], *, invocation_id: str) -> str: ...
|
||||||
|
def read_requirements_review(self, task_id: str, artifact_path: str = "") -> dict[str, Any] | None: ...
|
||||||
|
def write_requirements_review(self, task_id: str, payload: dict[str, Any], *, invocation_id: str) -> str: ...
|
||||||
|
def read_requirements_contract(self, task_id: str, artifact_path: str = "") -> dict[str, Any] | None: ...
|
||||||
|
def write_requirements_contract(self, task_id: str, payload: dict[str, Any], *, invocation_id: str = "") -> str: ...
|
||||||
|
def read_json(self, task_id: str, relative_path: str) -> dict[str, Any] | None: ...
|
||||||
|
def write_json_once(self, task_id: str, relative_path: str, payload: dict[str, Any]) -> str: ...
|
||||||
|
def write_text_once(self, task_id: str, relative_path: str, text: str) -> str: ...
|
||||||
|
def read_active_cdsl(self, task_id: str, revision_id: str) -> dict[str, Any] | None: ...
|
||||||
|
def read_topology(self, task_id: str, revision_id: str) -> dict[str, Any] | None: ...
|
||||||
|
def start_candidate_stage(self, task_id: str, idempotency_key: str, payload: dict[str, Any]) -> CandidateStage: ...
|
||||||
|
def stage_output_dir(self, task_id: str, stage_id: str) -> str: ...
|
||||||
|
def write_stage_json(self, task_id: str, stage_id: str, relative_path: str, payload: dict[str, Any]) -> str: ...
|
||||||
|
def read_stage_json(self, task_id: str, stage_id: str, relative_path: str) -> dict[str, Any] | None: ...
|
||||||
|
def publish_candidate(self, task_id: str, stage_id: str, revision_id: str) -> dict[str, str]: ...
|
||||||
|
def find_published_candidate(self, task_id: str, stage_id: str) -> tuple[str, dict[str, Any]] | None: ...
|
||||||
|
def recover_staged_candidates(self, task_id: str, referenced_stage_ids: set[str]) -> None: ...
|
||||||
|
|
||||||
|
|
||||||
|
class CadRuntime(Protocol):
|
||||||
|
def supported_atomic_ids(self) -> tuple[str, ...]: ...
|
||||||
|
def operation_contract(self, atomic_id: str) -> dict[str, Any]: ...
|
||||||
|
def selector_tokens(self, topology: dict[str, Any] | None) -> dict[str, dict[str, Any]]: ...
|
||||||
|
def reference_tokens(self, cdsl: dict[str, Any] | None) -> dict[str, str]: ...
|
||||||
|
def materialize_fragment(self, base_cdsl: dict[str, Any] | None, fragment: dict[str, Any], contract: dict[str, Any], selector_tokens: dict[str, dict[str, Any]], reference_tokens: dict[str, str], *, require_through: bool = False) -> tuple[dict[str, Any], dict[str, Any]]: ...
|
||||||
|
def rebuild(self, cdsl: dict[str, Any], output_dir: str, task_id: str, revision_id: str) -> dict[str, Any]: ...
|
||||||
|
|
||||||
|
|
||||||
|
class ModelGateway(Protocol):
|
||||||
|
async def call_tool(self, *, messages: list[dict[str, Any]], tool: dict[str, Any], provider_id: str, model_id: str, required_tool_name: str) -> dict[str, Any]: ...
|
||||||
|
async def conformance(self, *, provider_id: str, model_id: str, tools: list[dict[str, Any]]) -> dict[str, Any]: ...
|
||||||
|
|
||||||
|
|
||||||
|
class ReviewGateway(Protocol):
|
||||||
|
async def review(self, *, kind: str, payload: dict[str, Any], tool: dict[str, Any], provider_id: str, model_id: str) -> dict[str, Any]: ...
|
||||||
|
|
||||||
|
|
||||||
|
class VerifierExecutor(Protocol):
|
||||||
|
def evaluate(self, claims: list[dict[str, Any]], facts: dict[str, Any]) -> list[dict[str, Any]]: ...
|
||||||
|
|
||||||
|
|
||||||
|
class EventPublisher(Protocol):
|
||||||
|
async def publish(self, event: dict[str, Any]) -> None: ...
|
||||||
@@ -123,7 +123,7 @@ async def upload_conversation_attachment(
|
|||||||
if current is None:
|
if current is None:
|
||||||
raise HTTPException(status_code=404, detail="Conversation not found")
|
raise HTTPException(status_code=404, detail="Conversation not found")
|
||||||
active_task_id = str(current.get("current_task_id") or "")
|
active_task_id = str(current.get("current_task_id") or "")
|
||||||
active_task = store.read_task(active_task_id) if active_task_id else None
|
active_task = agent.v3.repository.get_task_projection(active_task_id) if active_task_id else None
|
||||||
if str((active_task or {}).get("lifecycle") or "") == "running":
|
if str((active_task or {}).get("lifecycle") or "") == "running":
|
||||||
raise HTTPException(status_code=409, detail="CAD task is running; attachments are locked until it reaches a terminal state")
|
raise HTTPException(status_code=409, detail="CAD task is running; attachments are locked until it reaches a terminal state")
|
||||||
kind = classify_upload(filename, file.content_type or "", len(data))
|
kind = classify_upload(filename, file.content_type or "", len(data))
|
||||||
@@ -144,25 +144,63 @@ async def upload_conversation_attachment(
|
|||||||
@app.get("/v1/tasks/{task_id}")
|
@app.get("/v1/tasks/{task_id}")
|
||||||
async def read_task(task_id: str) -> JSONResponse:
|
async def read_task(task_id: str) -> JSONResponse:
|
||||||
try:
|
try:
|
||||||
task = store.read_task(safe_task_id(task_id))
|
safe_id = safe_task_id(task_id)
|
||||||
|
task = agent.v3.repository.get_task_projection(safe_id)
|
||||||
except ValueError as error:
|
except ValueError as error:
|
||||||
raise HTTPException(status_code=400, detail=str(error)) from error
|
raise HTTPException(status_code=400, detail=str(error)) from error
|
||||||
if task is None:
|
if task is None:
|
||||||
raise HTTPException(status_code=404, detail="Task not found")
|
raise HTTPException(status_code=404, detail="Task not found")
|
||||||
# Keep the task endpoint self-contained for a reconnecting UI. Requirements
|
|
||||||
# are immutable during a run; the active revision remains preview-only.
|
|
||||||
task["preview_revision"] = str(task.get("active_revision") or task.get("current_revision") or "")
|
task["preview_revision"] = str(task.get("active_revision") or task.get("current_revision") or "")
|
||||||
requirements = store.read_requirements_document(task["task_id"])
|
state = agent.v3.repository.get_state(safe_id)
|
||||||
task["requirements_markdown"] = requirements or None
|
draft = agent.v3.artifacts.read_requirements_draft(safe_id, state.requirements_draft_path) if state is not None else None
|
||||||
completion_checklist = store.read_completion_checklist(task["task_id"])
|
task["requirements_draft"] = draft
|
||||||
task["completion_checklist_markdown"] = completion_checklist or None
|
task["requirements_review"] = agent.v3.artifacts.read_requirements_review(safe_id, state.requirements_review_path) if state is not None else None
|
||||||
modeling_plan = store.read_modeling_plan(task["task_id"])
|
task["requirements_contract"] = agent.v3.artifacts.read_requirements_contract(safe_id, state.requirements_contract_path) if state is not None else None
|
||||||
task["modeling_plan_markdown"] = modeling_plan or None
|
task["claim_summary"] = _claim_summary(task["requirements_contract"], task.get("action_ledger_summary"))
|
||||||
task["modeling_plan_review"] = store.read_modeling_plan_review(task["task_id"])
|
task["requirements_markdown"] = (agent.v3.artifacts.task_dir(safe_id) / "requirements.md").read_text(encoding="utf-8") if (agent.v3.artifacts.task_dir(safe_id) / "requirements.md").is_file() else None
|
||||||
task["agent_state"] = store.read_agent_state(task["task_id"])
|
task["completion_markdown"] = (agent.v3.artifacts.task_dir(safe_id) / "completion.md").read_text(encoding="utf-8") if (agent.v3.artifacts.task_dir(safe_id) / "completion.md").is_file() else None
|
||||||
|
task["usage"] = agent.v3.repository.usage_summary(safe_id)
|
||||||
return JSONResponse(task)
|
return JSONResponse(task)
|
||||||
|
|
||||||
|
|
||||||
|
def _claim_summary(contract: dict[str, Any] | None, ledger: Any) -> list[dict[str, Any]]:
|
||||||
|
"""Project frozen claims with the newest committed verification evidence.
|
||||||
|
|
||||||
|
This is API-only data derived from SQLite-backed ledger entries and the
|
||||||
|
state-referenced frozen contract. It never parses Markdown or exposes a
|
||||||
|
staged candidate as a task checkpoint.
|
||||||
|
"""
|
||||||
|
latest: dict[str, dict[str, Any]] = {}
|
||||||
|
for event in reversed(ledger if isinstance(ledger, list) else []):
|
||||||
|
if not isinstance(event, dict) or event.get("event") not in {"accepted", "completed"}:
|
||||||
|
continue
|
||||||
|
values = event.get("claim_results")
|
||||||
|
if not isinstance(values, list):
|
||||||
|
values = event.get("coverage")
|
||||||
|
if not isinstance(values, list):
|
||||||
|
continue
|
||||||
|
for value in values:
|
||||||
|
if isinstance(value, dict) and isinstance(value.get("claim_id"), str) and value["claim_id"] not in latest:
|
||||||
|
latest[value["claim_id"]] = value
|
||||||
|
result: list[dict[str, Any]] = []
|
||||||
|
for requirement in (contract or {}).get("requirements") or ():
|
||||||
|
if not isinstance(requirement, dict):
|
||||||
|
continue
|
||||||
|
for claim in requirement.get("acceptance_claims") or ():
|
||||||
|
if not isinstance(claim, dict) or not isinstance(claim.get("claim_id"), str):
|
||||||
|
continue
|
||||||
|
evidence = latest.get(claim["claim_id"], {})
|
||||||
|
result.append({
|
||||||
|
"requirement_id": str(requirement.get("requirement_id") or ""),
|
||||||
|
"claim_id": claim["claim_id"],
|
||||||
|
"claim_kind": str(claim.get("claim_kind") or ""),
|
||||||
|
"deterministic": claim.get("verification_mode") == "deterministic",
|
||||||
|
"status": str(evidence.get("status") or "pending"),
|
||||||
|
"evidence": evidence.get("evidence") if isinstance(evidence.get("evidence"), dict) else {},
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
@app.delete("/v1/tasks/{task_id}")
|
@app.delete("/v1/tasks/{task_id}")
|
||||||
async def cancel_task(task_id: str) -> JSONResponse:
|
async def cancel_task(task_id: str) -> JSONResponse:
|
||||||
try:
|
try:
|
||||||
@@ -175,34 +213,56 @@ async def cancel_task(task_id: str) -> JSONResponse:
|
|||||||
return JSONResponse(task)
|
return JSONResponse(task)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/v1/tasks/{task_id}/resume")
|
||||||
|
async def resume_task(task_id: str) -> JSONResponse:
|
||||||
|
"""Explicit recovery for a bounded infrastructure retry.
|
||||||
|
|
||||||
|
The workflow resumes its persisted source phase rather than inferring an
|
||||||
|
action from requirements, and never treats ``WAITING_FOR_USER`` as a
|
||||||
|
retryable service error.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
safe_id = safe_task_id(task_id)
|
||||||
|
except ValueError as error:
|
||||||
|
raise HTTPException(status_code=400, detail=str(error)) from error
|
||||||
|
try:
|
||||||
|
task = await agent.resume_retry(safe_id)
|
||||||
|
except ValueError as error:
|
||||||
|
raise HTTPException(status_code=409, detail=str(error)) from error
|
||||||
|
except Exception as error:
|
||||||
|
raise HTTPException(status_code=503, detail=f"CAD retry recovery is temporarily unavailable: {str(error)[:500]}") from error
|
||||||
|
if task is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Task not found")
|
||||||
|
return JSONResponse(task)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/v1/tasks/{task_id}/artifacts/{artifact_path:path}")
|
@app.get("/v1/tasks/{task_id}/artifacts/{artifact_path:path}")
|
||||||
async def read_artifact(task_id: str, artifact_path: str) -> StreamingResponse:
|
async def read_artifact(task_id: str, artifact_path: str) -> StreamingResponse:
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
|
|
||||||
try:
|
try:
|
||||||
safe_id = safe_task_id(task_id)
|
safe_id = safe_task_id(task_id)
|
||||||
path = store.artifact_path(safe_id, artifact_path)
|
path = agent.v3.artifacts.artifact_path(safe_id, artifact_path)
|
||||||
except ValueError as error:
|
except ValueError as error:
|
||||||
raise HTTPException(status_code=400, detail=str(error)) from error
|
raise HTTPException(status_code=400, detail=str(error)) from error
|
||||||
if not path.is_file():
|
if not path.is_file():
|
||||||
raise HTTPException(status_code=404, detail="Artifact not found")
|
raise HTTPException(status_code=404, detail="Artifact not found")
|
||||||
task = store.read_task(safe_id) or {}
|
task = agent.v3.repository.get_task_projection(safe_id) or {}
|
||||||
parts = artifact_path.split("/")
|
parts = artifact_path.split("/")
|
||||||
revision_id = parts[1] if len(parts) >= 3 and parts[0] == "revisions" else ""
|
revision_id = parts[1] if len(parts) >= 3 and parts[0] == "revisions" else ""
|
||||||
revision = next((item for item in task.get("revisions") or () if isinstance(item, dict) and item.get("revision_id") == revision_id), None)
|
published_revision = str(task.get("active_revision") or "") if str(task.get("lifecycle") or "") == "completed" else ""
|
||||||
published_revision = str(task.get("published_revision") or "")
|
|
||||||
active_revision = str(task.get("active_revision") or task.get("current_revision") or "")
|
active_revision = str(task.get("active_revision") or task.get("current_revision") or "")
|
||||||
if not isinstance(revision, dict):
|
if not revision_id:
|
||||||
# The task directory also contains candidate staging, agent audit and
|
# The task directory also contains candidate staging, agent audit and
|
||||||
# frozen-input files. None of those are a public artifact surface.
|
# frozen-input files. None of those are a public artifact surface.
|
||||||
raise HTTPException(status_code=403, detail="This task artifact is not public")
|
raise HTTPException(status_code=403, detail="This task artifact is not public")
|
||||||
|
|
||||||
if revision_id == published_revision:
|
if revision_id == published_revision:
|
||||||
published_paths = {
|
published_paths = {
|
||||||
str(revision.get("cdsl_path") or ""),
|
f"revisions/{revision_id}/model.cdsl.json",
|
||||||
str(revision.get("step_path") or ""),
|
f"revisions/{revision_id}/model.step",
|
||||||
str(revision.get("glb_path") or ""),
|
f"revisions/{revision_id}/model.glb",
|
||||||
str(revision.get("report_path") or ""),
|
f"revisions/{revision_id}/rebuild-report.json",
|
||||||
}
|
}
|
||||||
if artifact_path not in published_paths:
|
if artifact_path not in published_paths:
|
||||||
raise HTTPException(status_code=403, detail="Only final delivery artifacts are downloadable")
|
raise HTTPException(status_code=403, detail="Only final delivery artifacts are downloadable")
|
||||||
@@ -213,7 +273,7 @@ async def read_artifact(task_id: str, artifact_path: str) -> StreamingResponse:
|
|||||||
if (
|
if (
|
||||||
str(task.get("lifecycle") or "") != "running"
|
str(task.get("lifecycle") or "") != "running"
|
||||||
or revision_id != active_revision
|
or revision_id != active_revision
|
||||||
or artifact_path != str(revision.get("glb_path") or "")
|
or artifact_path != f"revisions/{revision_id}/model.glb"
|
||||||
):
|
):
|
||||||
raise HTTPException(status_code=403, detail="Only the published revision is downloadable")
|
raise HTTPException(status_code=403, detail="Only the published revision is downloadable")
|
||||||
return FileResponse(path, media_type="model/gltf-binary", headers={"Content-Disposition": "inline"})
|
return FileResponse(path, media_type="model/gltf-binary", headers={"Content-Disposition": "inline"})
|
||||||
|
|||||||
@@ -1,24 +1,24 @@
|
|||||||
"""HTTP-facing lifecycle owner for the autonomous CDSL author."""
|
"""HTTP/SSE delivery adapter for the protocol v3 workflow."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import base64
|
from collections.abc import AsyncIterator
|
||||||
from collections.abc import AsyncIterator, Awaitable, Callable
|
from hashlib import sha256
|
||||||
import json
|
import re
|
||||||
import secrets
|
import secrets
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import httpx
|
from app.cad_agent.application.workflow import ModelIdentity
|
||||||
|
from app.cad_agent.application.capabilities import verify_model_capability
|
||||||
|
from app.cad_agent.composition import V3Services, compose_v3
|
||||||
|
from app.cad_agent.domain.errors import ErrorCode
|
||||||
|
from app.cad_agent.domain.state import TaskPhase, transition
|
||||||
from app.models.contracts import ChatMessage
|
from app.models.contracts import ChatMessage
|
||||||
from app.services.autonomous_cdsl_generation import AutonomousCdslGenerationRunner
|
|
||||||
from app.services.library import CdslLibrary
|
from app.services.library import CdslLibrary
|
||||||
from app.services.review_renderer import renderer_status
|
|
||||||
from app.services.sse import event
|
from app.services.sse import event
|
||||||
from app.services.storage import WorkspaceStore, now_iso
|
from app.services.storage import WorkspaceStore, now_iso
|
||||||
from app.settings import ProviderConfig, ProviderModel, Settings
|
from app.settings import Settings
|
||||||
|
|
||||||
|
|
||||||
def text_from_message(message: ChatMessage) -> str:
|
def text_from_message(message: ChatMessage) -> str:
|
||||||
@@ -32,48 +32,25 @@ def _response_language(text: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
_EVENT_LABELS = {
|
_EVENT_LABELS = {
|
||||||
"requirements_document": "冻结需求", "completion_checklist": "完成清单", "completion_audit": "完成审计",
|
"requirements_review": "需求合同独立复核",
|
||||||
"modeling_plan": "建模计划", "modeling_plan_review": "计划独立复核",
|
"action_selection": "动作选择",
|
||||||
"tool_call": "建模工具", "candidate_result": "候选构建", "candidate_review": "候选独立复核",
|
"tool_call": "建模工具",
|
||||||
"geometry_diagnostic": "几何诊断", "geometry_conclusion": "几何结论", "step_review": "步骤审查",
|
"candidate_result": "候选构建",
|
||||||
"checkpoint": "构建检查点", "rollback": "回滚检查点", "final_review": "最终视觉复核", "task_terminal": "生成任务",
|
"candidate_review": "候选独立复核",
|
||||||
|
"final_review": "最终独立复核",
|
||||||
|
"task_terminal": "生成任务",
|
||||||
|
"state_changed": "任务状态",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _event_status(name: str, payload: dict[str, Any]) -> str:
|
|
||||||
if name == "task_terminal":
|
|
||||||
lifecycle = str(payload.get("lifecycle") or "")
|
|
||||||
if lifecycle == "failed":
|
|
||||||
return "error"
|
|
||||||
if lifecycle == "cancelled":
|
|
||||||
return "cancelled"
|
|
||||||
return "success"
|
|
||||||
review = payload.get("review") if isinstance(payload.get("review"), dict) else {}
|
|
||||||
if (name == "modeling_plan_review" and str(review.get("verdict") or "") == "revise") or (
|
|
||||||
name == "candidate_review" and str(review.get("verdict") or "") == "reject"
|
|
||||||
) or (
|
|
||||||
name == "final_review" and str(review.get("verdict") or "") == "repair"
|
|
||||||
):
|
|
||||||
return "error"
|
|
||||||
return str(payload.get("status") or "running")
|
|
||||||
|
|
||||||
|
|
||||||
def _visible_progress(name: str, payload: dict[str, Any]) -> dict[str, Any]:
|
def _visible_progress(name: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
data = dict(payload)
|
lifecycle = str(payload.get("lifecycle") or "")
|
||||||
data.update({"step": name, "label": _EVENT_LABELS.get(name, name), "status": _event_status(name, payload)})
|
result = payload.get("result") if isinstance(payload.get("result"), dict) else {}
|
||||||
return data
|
status = "error" if lifecycle == "failed" or str(payload.get("status") or "") == "error" or str(result.get("status") or "") in {"rejected", "repair"} else "waiting" if lifecycle in {"waiting_for_user", "waiting_retry"} else "success" if lifecycle == "completed" else str(payload.get("status") or "running")
|
||||||
|
return {**payload, "step": name, "label": _EVENT_LABELS.get(name, name), "status": status}
|
||||||
|
|
||||||
|
|
||||||
def _append_text_part(parts: list[dict[str, Any]], text: str) -> None:
|
def _upsert_part(parts: list[dict[str, Any]], part: dict[str, Any]) -> None:
|
||||||
if not text:
|
|
||||||
return
|
|
||||||
if parts and parts[-1].get("type") == "text":
|
|
||||||
parts[-1]["text"] = str(parts[-1].get("text") or "") + text
|
|
||||||
else:
|
|
||||||
parts.append({"type": "text", "text": text})
|
|
||||||
|
|
||||||
|
|
||||||
def _upsert_data_part(parts: list[dict[str, Any]], part: dict[str, Any]) -> None:
|
|
||||||
part_id = str(part.get("id") or "")
|
part_id = str(part.get("id") or "")
|
||||||
if part_id:
|
if part_id:
|
||||||
for index, existing in enumerate(parts):
|
for index, existing in enumerate(parts):
|
||||||
@@ -83,128 +60,180 @@ def _upsert_data_part(parts: list[dict[str, Any]], part: dict[str, Any]) -> None
|
|||||||
parts.append(part)
|
parts.append(part)
|
||||||
|
|
||||||
|
|
||||||
class _StreamingUnsupported(RuntimeError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def conversation_user_context(conversation: dict[str, Any]) -> list[dict[str, str]]:
|
|
||||||
"""Return durable user intent from the whole conversation.
|
|
||||||
|
|
||||||
A terminal CAD task always starts a fresh frozen requirements document,
|
|
||||||
but it must not lose the earlier user messages that explain what a short
|
|
||||||
follow-up means. This deliberately has no keyword handling: ``retry``,
|
|
||||||
``change it`` and a full replacement prompt are all ordinary conversation
|
|
||||||
turns. Assistant prose is excluded because it is generated evidence, not
|
|
||||||
user intent.
|
|
||||||
"""
|
|
||||||
entries: list[str] = []
|
|
||||||
for message in conversation.get("messages") or ():
|
|
||||||
if not isinstance(message, dict) or message.get("role") != "user":
|
|
||||||
continue
|
|
||||||
parts = message.get("parts") or ()
|
|
||||||
text = "\n".join(
|
|
||||||
str(part.get("text") or "")
|
|
||||||
for part in parts
|
|
||||||
if isinstance(part, dict) and str(part.get("type") or "") == "text"
|
|
||||||
).strip()
|
|
||||||
if text:
|
|
||||||
entries.append(text)
|
|
||||||
if not entries:
|
|
||||||
return []
|
|
||||||
# Bound history without giving an arbitrary short follow-up precedence
|
|
||||||
# over its preceding detailed request. The latest user turn remains last.
|
|
||||||
selected: list[str] = []
|
|
||||||
remaining = 18_000
|
|
||||||
for text in reversed(entries):
|
|
||||||
if len(text) > remaining and selected:
|
|
||||||
break
|
|
||||||
selected.append(text[-remaining:])
|
|
||||||
remaining -= len(selected[-1])
|
|
||||||
if remaining <= 0 or len(selected) >= 12:
|
|
||||||
break
|
|
||||||
selected.reverse()
|
|
||||||
return [{
|
|
||||||
"role": "user",
|
|
||||||
"content": "Conversation user context, ordered from earlier to latest. Use it to interpret the current CAD request; do not treat short follow-ups as a replacement unless they explicitly say so:\n\n"
|
|
||||||
+ "\n\n--- Next user turn ---\n\n".join(selected),
|
|
||||||
}]
|
|
||||||
|
|
||||||
|
|
||||||
class AgentService:
|
class AgentService:
|
||||||
|
"""Delivery boundary: no CAD state transitions or provider calls live here."""
|
||||||
|
|
||||||
def __init__(self, settings: Settings, store: WorkspaceStore, library: CdslLibrary) -> None:
|
def __init__(self, settings: Settings, store: WorkspaceStore, library: CdslLibrary) -> None:
|
||||||
self.settings = settings
|
self.settings = settings
|
||||||
self.store = store
|
self.store = store # Conversation/attachment store, not v3 CAD state.
|
||||||
self.library = library
|
self.library = library
|
||||||
|
self.v3: V3Services = compose_v3(settings)
|
||||||
self._autonomous_runs: dict[str, asyncio.Task[None]] = {}
|
self._autonomous_runs: dict[str, asyncio.Task[None]] = {}
|
||||||
|
|
||||||
async def resume_running_tasks(self) -> None:
|
async def resume_running_tasks(self) -> None:
|
||||||
"""Restart only durable native autonomous workers after process recovery."""
|
task_ids = self.v3.repository.running_task_ids()
|
||||||
for task in self.store.running_tasks():
|
if not task_ids:
|
||||||
task_id = str(task.get("task_id") or "")
|
return
|
||||||
state = self.store.read_agent_state(task_id) if task_id else None
|
try:
|
||||||
if not task_id or task_id in self._autonomous_runs:
|
author_provider, author_model = self.settings.resolve_model(None, None)
|
||||||
|
review_provider, review_model = self.settings.resolve_independent_review_model(author_provider, author_model)
|
||||||
|
except ValueError as error:
|
||||||
|
await self._park_startup_tasks(
|
||||||
|
task_ids,
|
||||||
|
ErrorCode.MODEL_STRUCTURED_OUTPUT_UNSUPPORTED,
|
||||||
|
str(error),
|
||||||
|
retryable=False,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
author_capability = await verify_model_capability(
|
||||||
|
self.v3.repository,
|
||||||
|
self.v3.workflow.runtime,
|
||||||
|
self.v3.models,
|
||||||
|
provider_id=author_provider.id,
|
||||||
|
model_id=author_model.id,
|
||||||
|
)
|
||||||
|
reviewer_capability = await verify_model_capability(
|
||||||
|
self.v3.repository,
|
||||||
|
self.v3.workflow.runtime,
|
||||||
|
self.v3.models,
|
||||||
|
provider_id=review_provider.id,
|
||||||
|
model_id=review_model.id,
|
||||||
|
)
|
||||||
|
except Exception as error:
|
||||||
|
# Startup recovery must never bypass a production capability gate.
|
||||||
|
# Connectivity failures are recoverable, but they may not leave a
|
||||||
|
# task falsely marked running without a worker.
|
||||||
|
await self._park_startup_tasks(
|
||||||
|
task_ids,
|
||||||
|
ErrorCode.AUTHOR_TRANSPORT_UNAVAILABLE,
|
||||||
|
str(error),
|
||||||
|
retryable=True,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if not author_capability.get("supported") or not reviewer_capability.get("supported"):
|
||||||
|
await self._park_startup_tasks(
|
||||||
|
task_ids,
|
||||||
|
ErrorCode.MODEL_STRUCTURED_OUTPUT_UNSUPPORTED,
|
||||||
|
"Selected author or reviewer did not pass the v3 conformance suite.",
|
||||||
|
retryable=False,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
for task_id in task_ids:
|
||||||
|
if task_id in self._autonomous_runs:
|
||||||
continue
|
continue
|
||||||
if str(task.get("schema_version") or "") != "2.0" or not isinstance(state, dict):
|
self._autonomous_runs[task_id] = asyncio.create_task(
|
||||||
self.store.finish_generation(task_id, lifecycle="failed", failure={
|
self._consume_discarding(task_id, ModelIdentity(author_provider.id, author_model.id), ModelIdentity(review_provider.id, review_model.id)),
|
||||||
"schema_version": "cad.autonomous-failure.v1",
|
name=f"resume-autonomous-cad-v3-{task_id}",
|
||||||
"stage": "recovery",
|
)
|
||||||
"error_code": "AUTONOMOUS_PROTOCOL_OBSOLETE",
|
|
||||||
"message": "This running task uses the retired planning/strict-authoring protocol and cannot be resumed.",
|
async def _park_startup_tasks(
|
||||||
})
|
self,
|
||||||
|
task_ids: list[str],
|
||||||
|
error: ErrorCode,
|
||||||
|
message: str,
|
||||||
|
*,
|
||||||
|
retryable: bool,
|
||||||
|
) -> None:
|
||||||
|
"""Make a failed startup gate durable instead of leaving phantom runs."""
|
||||||
|
event_name = "waiting_retry" if retryable else "failed_model_capability"
|
||||||
|
for task_id in task_ids:
|
||||||
|
state = self.v3.repository.get_state(task_id)
|
||||||
|
if state is None or task_id in self._autonomous_runs:
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
provider, model = self.settings.resolve_model(str(state.get("provider_id") or ""), str(state.get("model_id") or ""))
|
next_state = transition(
|
||||||
except ValueError as error:
|
state,
|
||||||
self.store.finish_generation(task_id, lifecycle="failed", failure={
|
"waiting_retry" if retryable else "failed",
|
||||||
"schema_version": "cad.autonomous-failure.v1", "stage": "recovery", "message": str(error),
|
error=error,
|
||||||
})
|
)
|
||||||
|
except ValueError:
|
||||||
continue
|
continue
|
||||||
try:
|
if self.v3.repository.compare_and_swap(next_state, events=[{
|
||||||
self.settings.resolve_independent_review_model(provider, model)
|
"event": event_name,
|
||||||
renderer_ready, renderer_detail = renderer_status()
|
"code": error.value,
|
||||||
if not renderer_ready:
|
"message": message[:1000],
|
||||||
raise ValueError(renderer_detail)
|
"startup_recovery": True,
|
||||||
except ValueError as error:
|
}]):
|
||||||
self.store.finish_generation(task_id, lifecycle="failed", failure={
|
await self.v3.outbox.dispatch_pending(task_id=task_id)
|
||||||
"schema_version": "cad.autonomous-failure.v1",
|
|
||||||
"stage": "recovery",
|
|
||||||
"error_code": "FINAL_REVIEW_UNAVAILABLE",
|
|
||||||
"message": str(error),
|
|
||||||
})
|
|
||||||
continue
|
|
||||||
request = str(state.get("request") or task.get("request") or "")
|
|
||||||
conversation_id = str(state.get("conversation_id") or "")
|
|
||||||
initial_messages = state.get("initial_messages") if isinstance(state.get("initial_messages"), list) else []
|
|
||||||
runner = AutonomousCdslGenerationRunner(self.settings, self.store, self._complete)
|
|
||||||
|
|
||||||
async def consume(
|
async def _consume_discarding(self, task_id: str, author: ModelIdentity, reviewer: ModelIdentity) -> None:
|
||||||
*, task_id: str = task_id, request: str = request, conversation_id: str = conversation_id,
|
try:
|
||||||
provider: ProviderConfig = provider, model: ProviderModel = model,
|
async for _name, _payload in self.v3.workflow.run(task_id=task_id, author=author, reviewer=reviewer):
|
||||||
initial_messages: list[dict[str, Any]] = initial_messages, runner: AutonomousCdslGenerationRunner = runner,
|
# A resumed task has no active SSE client, but its durable
|
||||||
) -> None:
|
# state events must still leave the transactional outbox.
|
||||||
try:
|
await self.v3.outbox.dispatch_pending(task_id=task_id)
|
||||||
async for _name, _payload in runner.run(
|
finally:
|
||||||
task_id=task_id, request=request, conversation_id=conversation_id, provider=provider,
|
# Flush a final transition emitted immediately before the worker
|
||||||
model=model, initial_messages=initial_messages, already_started=True,
|
# exits, such as WAITING_RETRY or FAILED_INTERNAL.
|
||||||
):
|
await self.v3.outbox.dispatch_pending(task_id=task_id)
|
||||||
pass
|
self._autonomous_runs.pop(task_id, None)
|
||||||
finally:
|
|
||||||
self._autonomous_runs.pop(task_id, None)
|
|
||||||
|
|
||||||
self._autonomous_runs[task_id] = asyncio.create_task(consume(), name=f"resume-autonomous-cdsl-{task_id}")
|
|
||||||
|
|
||||||
async def cancel(self, task_id: str) -> dict[str, Any] | None:
|
async def cancel(self, task_id: str) -> dict[str, Any] | None:
|
||||||
task = self.store.read_task(task_id)
|
state = self.v3.repository.get_state(task_id)
|
||||||
if task is None:
|
if state is None:
|
||||||
return None
|
return None
|
||||||
|
if state.phase not in {TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED}:
|
||||||
|
cancelled = transition(state, "cancelled", error=ErrorCode.CANCELLED)
|
||||||
|
if self.v3.repository.compare_and_swap(cancelled, events=[{
|
||||||
|
"event": "task_cancelled",
|
||||||
|
"from_phase": state.phase.value,
|
||||||
|
"active_revision": state.active_revision,
|
||||||
|
"checkpoint_preserved": bool(state.active_revision),
|
||||||
|
}]):
|
||||||
|
await self.v3.outbox.dispatch_pending(task_id=task_id)
|
||||||
|
# Persist the terminal transition before interrupting the coroutine.
|
||||||
|
# A concurrent worker will fail its optimistic CAS instead of reviving
|
||||||
|
# the task after the caller has requested cancellation.
|
||||||
running = self._autonomous_runs.pop(task_id, None)
|
running = self._autonomous_runs.pop(task_id, None)
|
||||||
if running and not running.done():
|
if running and not running.done():
|
||||||
running.cancel()
|
running.cancel()
|
||||||
if str(task.get("lifecycle") or "") == "running":
|
return self.v3.repository.get_task_projection(task_id)
|
||||||
task = self.store.finish_generation(task_id, lifecycle="cancelled", failure={
|
|
||||||
"schema_version": "cad.autonomous-cancelled.v1", "stage": "cancelled", "message": "CAD generation was cancelled by the user.",
|
async def resume_retry(self, task_id: str) -> dict[str, Any] | None:
|
||||||
})
|
"""Explicitly restart one durably parked infrastructure retry.
|
||||||
return task
|
|
||||||
|
``WAITING_FOR_USER`` is deliberately excluded: it requires new user
|
||||||
|
input, while this endpoint is only the controlled recovery route for
|
||||||
|
bounded provider/render/storage failures.
|
||||||
|
"""
|
||||||
|
state = self.v3.repository.get_state(task_id)
|
||||||
|
if state is None:
|
||||||
|
return None
|
||||||
|
if state.phase.value != "WAITING_RETRY":
|
||||||
|
raise ValueError("Only a WAITING_RETRY CAD task can be resumed through this endpoint")
|
||||||
|
running = self._autonomous_runs.get(task_id)
|
||||||
|
if running is not None and not running.done():
|
||||||
|
raise ValueError("The CAD task is already running")
|
||||||
|
author_provider, author_model = self.settings.resolve_model(None, None)
|
||||||
|
review_provider, review_model = self.settings.resolve_independent_review_model(author_provider, author_model)
|
||||||
|
author_capability = await verify_model_capability(
|
||||||
|
self.v3.repository,
|
||||||
|
self.v3.workflow.runtime,
|
||||||
|
self.v3.models,
|
||||||
|
provider_id=author_provider.id,
|
||||||
|
model_id=author_model.id,
|
||||||
|
)
|
||||||
|
reviewer_capability = await verify_model_capability(
|
||||||
|
self.v3.repository,
|
||||||
|
self.v3.workflow.runtime,
|
||||||
|
self.v3.models,
|
||||||
|
provider_id=review_provider.id,
|
||||||
|
model_id=review_model.id,
|
||||||
|
)
|
||||||
|
if not author_capability.get("supported") or not reviewer_capability.get("supported"):
|
||||||
|
raise ValueError("MODEL_STRUCTURED_OUTPUT_UNSUPPORTED: selected author or reviewer did not pass the v3 conformance suite")
|
||||||
|
if not self.v3.workflow.resume(task_id):
|
||||||
|
raise ValueError("The CAD task no longer has a recoverable retry checkpoint")
|
||||||
|
self._autonomous_runs[task_id] = asyncio.create_task(
|
||||||
|
self._consume_discarding(
|
||||||
|
task_id,
|
||||||
|
ModelIdentity(author_provider.id, author_model.id),
|
||||||
|
ModelIdentity(review_provider.id, review_model.id),
|
||||||
|
),
|
||||||
|
name=f"resume-autonomous-cad-v3-{task_id}",
|
||||||
|
)
|
||||||
|
return self.v3.repository.get_task_projection(task_id)
|
||||||
|
|
||||||
async def stream(
|
async def stream(
|
||||||
self,
|
self,
|
||||||
@@ -215,150 +244,136 @@ class AgentService:
|
|||||||
model_id: str | None = None,
|
model_id: str | None = None,
|
||||||
viewer_context: list[dict[str, Any]] | None = None,
|
viewer_context: list[dict[str, Any]] | None = None,
|
||||||
) -> AsyncIterator[bytes]:
|
) -> AsyncIterator[bytes]:
|
||||||
del viewer_context # Viewer topology remains frozen while a run executes.
|
del viewer_context
|
||||||
latest_user = next((message for message in reversed(messages) if message.role == "user"), None)
|
latest_user = next((message for message in reversed(messages) if message.role == "user"), None)
|
||||||
if latest_user is None:
|
if latest_user is None or not text_from_message(latest_user):
|
||||||
yield event("cad_error", {"stage": "request", "message": "A user message is required."})
|
|
||||||
yield event("done", {})
|
|
||||||
return
|
|
||||||
user_text = text_from_message(latest_user)
|
|
||||||
if not user_text:
|
|
||||||
yield event("cad_error", {"stage": "request", "message": "A non-empty user request is required."})
|
yield event("cad_error", {"stage": "request", "message": "A non-empty user request is required."})
|
||||||
yield event("done", {})
|
yield event("done", {})
|
||||||
return
|
return
|
||||||
|
request = text_from_message(latest_user)
|
||||||
conversation = self.store.ensure_conversation(conversation_id)
|
conversation = self.store.ensure_conversation(conversation_id)
|
||||||
task_id = str(selected_task_id or conversation.get("current_task_id") or "")
|
selected = str(selected_task_id or conversation.get("current_task_id") or "")
|
||||||
current_task = self.store.read_task(task_id) if task_id else None
|
current = self.v3.repository.get_task_projection(selected) if selected else None
|
||||||
if task_id and current_task is None:
|
resumed_task_id = ""
|
||||||
yield event("cad_error", {"stage": "request", "message": "The selected CAD task no longer exists."})
|
if current and str(current.get("lifecycle") or "") == "waiting_for_user":
|
||||||
yield event("done", {})
|
state = self.v3.repository.get_state(selected)
|
||||||
return
|
if state is not None:
|
||||||
if current_task and str(current_task.get("lifecycle") or "") == "running":
|
terminal = self.v3.workflow.waiting_for_user_terminal(selected, state)
|
||||||
yield event("cad_error", {"stage": "request", "message": "该 CAD 任务正在生成,完成或失败前不能继续对话。"})
|
fields = [
|
||||||
yield event("done", {})
|
{
|
||||||
return
|
"path": f"/requirements/{str(item.get('draftId') or 'review')}",
|
||||||
|
"message": str(item.get("question") or item.get("reasonCode") or "Unresolved review item."),
|
||||||
|
}
|
||||||
|
for item in terminal.get("unresolved") or ()
|
||||||
|
if isinstance(item, dict)
|
||||||
|
]
|
||||||
|
fields.extend({"path": "/requirements/review", "message": issue} for issue in terminal.get("issues") or ())
|
||||||
|
if not self.v3.workflow.resume_with_user_clarification(selected, request, message_id=latest_user.id):
|
||||||
|
yield event("cad_error", {
|
||||||
|
"stage": "request",
|
||||||
|
"message": "This parked CAD task cannot apply the supplied clarification. " + str(terminal["message"]),
|
||||||
|
"fieldErrors": fields,
|
||||||
|
"taskId": selected,
|
||||||
|
"blockerType": terminal.get("blockerType"),
|
||||||
|
"userActionRequired": terminal.get("userActionRequired"),
|
||||||
|
})
|
||||||
|
yield event("done", {})
|
||||||
|
return
|
||||||
|
resumed_task_id = selected
|
||||||
try:
|
try:
|
||||||
provider, model = self.settings.resolve_model(provider_id, model_id)
|
author_provider, author_model = self.settings.resolve_model(provider_id, model_id)
|
||||||
|
review_provider, review_model = self.settings.resolve_independent_review_model(author_provider, author_model)
|
||||||
except ValueError as error:
|
except ValueError as error:
|
||||||
yield event("cad_error", {"stage": "configuration", "message": str(error)})
|
yield event("cad_error", {"stage": "configuration", "message": str(error)})
|
||||||
yield event("done", {})
|
yield event("done", {})
|
||||||
return
|
return
|
||||||
# This is a fail-closed delivery gate, not a per-checkpoint cost. A
|
|
||||||
# model must never spend a long authoring run only to discover at final
|
|
||||||
# publication that independent visual review cannot run.
|
|
||||||
try:
|
try:
|
||||||
self.settings.resolve_independent_review_model(provider, model)
|
author_capability = await verify_model_capability(self.v3.repository, self.v3.workflow.runtime, self.v3.models, provider_id=author_provider.id, model_id=author_model.id)
|
||||||
renderer_ready, renderer_detail = renderer_status()
|
reviewer_capability = await verify_model_capability(self.v3.repository, self.v3.workflow.runtime, self.v3.models, provider_id=review_provider.id, model_id=review_model.id)
|
||||||
if not renderer_ready:
|
except Exception as error:
|
||||||
raise ValueError(renderer_detail)
|
yield event("cad_error", {"stage": "configuration", "message": f"MODEL_STRUCTURED_OUTPUT_UNSUPPORTED: {str(error)[:500]}"})
|
||||||
except ValueError as error:
|
|
||||||
yield event("cad_error", {"stage": "configuration", "message": f"最终视觉复核不可用:{error}"})
|
|
||||||
yield event("done", {})
|
yield event("done", {})
|
||||||
return
|
return
|
||||||
try:
|
if not author_capability.get("supported") or not reviewer_capability.get("supported"):
|
||||||
attachment_content = self._attachment_message(conversation, model)
|
yield event("cad_error", {"stage": "configuration", "message": "MODEL_STRUCTURED_OUTPUT_UNSUPPORTED: selected author or reviewer did not pass the v3 conformance suite."})
|
||||||
except ValueError as error:
|
|
||||||
yield event("cad_error", {"stage": "attachment", "message": str(error)})
|
|
||||||
yield event("done", {})
|
yield event("done", {})
|
||||||
return
|
return
|
||||||
if not task_id:
|
if current and str(current.get("lifecycle") or "") == "running":
|
||||||
created = self.store.ensure_task(None, user_text)
|
yield event("cad_error", {"stage": "request", "message": "该 CAD 任务正在生成,完成或失败前不能继续对话。"})
|
||||||
task_id = str(created["task_id"])
|
|
||||||
current_task = created
|
|
||||||
elif str(current_task.get("schema_version") or "") != "2.0":
|
|
||||||
yield event("cad_error", {"stage": "generation", "message": "AUTONOMOUS_PROTOCOL_OBSOLETE: this task belongs to the retired protocol. Start a new CAD task."})
|
|
||||||
yield event("done", {})
|
yield event("done", {})
|
||||||
return
|
return
|
||||||
else:
|
# A terminal task is immutable. A legacy task is never migrated: both
|
||||||
# requirements.md is immutable for a task. A post-terminal user
|
# cases intentionally create a fresh v3 task.
|
||||||
# request therefore starts a clean autonomous run instead of
|
task_id = resumed_task_id or f"cad_{secrets.token_hex(6)}"
|
||||||
# silently mixing two independent frozen specifications.
|
if not resumed_task_id:
|
||||||
created = self.store.ensure_task(None, user_text)
|
try:
|
||||||
task_id = str(created["task_id"])
|
source_blocks = self._task_source_blocks(conversation, request)
|
||||||
current_task = created
|
self.v3.workflow.create_task(task_id, request, source_blocks=source_blocks)
|
||||||
|
except ValueError as error:
|
||||||
|
yield event("cad_error", {"stage": "request", "message": str(error)})
|
||||||
|
yield event("done", {})
|
||||||
|
return
|
||||||
|
conversation = self.store.append_conversation_message(conversation["conversation_id"], latest_user.model_dump(), task_id)
|
||||||
|
yield event("progress", {"taskId": task_id, "step": "task_started", "label": "Agent", "status": "running", "message": "已应用补充说明并恢复 CAD 任务。" if resumed_task_id else "CAD 任务已启动。" if _response_language(request) == "Chinese" else "CAD task started."})
|
||||||
|
|
||||||
conversation = self.store.append_conversation_message(
|
|
||||||
conversation["conversation_id"], latest_user.model_dump(), task_id,
|
|
||||||
)
|
|
||||||
initial_messages: list[dict[str, Any]] = conversation_user_context(conversation)
|
|
||||||
if attachment_content:
|
|
||||||
initial_messages.append({"role": "user", "content": attachment_content})
|
|
||||||
frozen_attachment_ids = [
|
|
||||||
str(attachment.get("id") or "")
|
|
||||||
for attachment in conversation.get("attachments") or ()
|
|
||||||
if isinstance(attachment, dict) and str(attachment.get("id") or "")
|
|
||||||
]
|
|
||||||
self.store.start_generation(task_id, request=user_text)
|
|
||||||
yield event("progress", {
|
|
||||||
"taskId": task_id, "step": "task_started", "label": "Agent", "status": "running",
|
|
||||||
"message": "CAD 任务已启动。" if _response_language(user_text) == "Chinese" else "CAD task started.",
|
|
||||||
})
|
|
||||||
queue: asyncio.Queue[tuple[str, dict[str, Any]] | None] = asyncio.Queue()
|
queue: asyncio.Queue[tuple[str, dict[str, Any]] | None] = asyncio.Queue()
|
||||||
assistant_parts: list[dict[str, Any]] = []
|
parts: list[dict[str, Any]] = []
|
||||||
sequence = 0
|
sequence = 0
|
||||||
|
|
||||||
async def on_text_delta(text: str) -> None:
|
|
||||||
# Text is persisted and streamed through the same ordered queue as
|
|
||||||
# tool events, so a refresh produces the exact same transcript.
|
|
||||||
nonlocal sequence
|
|
||||||
sequence += 1
|
|
||||||
_append_text_part(assistant_parts, text)
|
|
||||||
await queue.put(("text_delta", {
|
|
||||||
"text": text, "taskId": task_id, "eventId": f"{task_id}_{sequence}_text_delta",
|
|
||||||
"sequence": sequence, "timestamp": now_iso(),
|
|
||||||
}))
|
|
||||||
|
|
||||||
runner = AutonomousCdslGenerationRunner(self.settings, self.store, self._complete, on_text_delta=on_text_delta)
|
|
||||||
assistant_id = f"assistant_{secrets.token_hex(8)}"
|
|
||||||
|
|
||||||
async def consume() -> None:
|
async def consume() -> None:
|
||||||
nonlocal sequence
|
nonlocal sequence
|
||||||
terminal = ""
|
|
||||||
try:
|
async def dispatch_state_events() -> None:
|
||||||
async for name, payload in runner.run(
|
"""Publish state notifications only through the outbox.
|
||||||
task_id=task_id, request=user_text, conversation_id=conversation["conversation_id"], provider=provider,
|
|
||||||
model=model, initial_messages=initial_messages, frozen_attachment_ids=frozen_attachment_ids, already_started=True,
|
The dispatcher preserves at-least-once semantics. The SSE
|
||||||
):
|
event ID derives from the durable outbox row, so reconnecting
|
||||||
|
clients can safely de-duplicate a delivery replay.
|
||||||
|
"""
|
||||||
|
nonlocal sequence
|
||||||
|
for outbox_event in await self.v3.outbox.dispatch_pending(task_id=task_id):
|
||||||
sequence += 1
|
sequence += 1
|
||||||
decorated = dict(payload)
|
event_name = str(outbox_event.get("event") or "state_changed")
|
||||||
decorated.setdefault("taskId", task_id)
|
payload = {
|
||||||
decorated.setdefault("eventId", f"{task_id}_{sequence}_{name}")
|
"taskId": task_id,
|
||||||
decorated.update({
|
"eventId": f"outbox_{outbox_event['event_id']}",
|
||||||
"sequence": sequence,
|
"sequence": sequence,
|
||||||
"timestamp": now_iso(),
|
"timestamp": now_iso(),
|
||||||
})
|
"outboxEvent": event_name,
|
||||||
if name == "cad_result":
|
"message": event_name.replace("_", " "),
|
||||||
_upsert_data_part(assistant_parts, {"type": "data-cad-result", "id": decorated["eventId"], "data": decorated})
|
}
|
||||||
elif name == "agent_thinking":
|
await queue.put(("progress", _visible_progress("state_changed", payload)))
|
||||||
visible = str(payload.get("message") or "")
|
|
||||||
if visible:
|
try:
|
||||||
# Streaming completions already delivered this
|
async for name, payload in self.v3.workflow.run(
|
||||||
# content through on_text_delta; only persist the
|
task_id=task_id,
|
||||||
# full response for the compatibility path.
|
author=ModelIdentity(author_provider.id, author_model.id),
|
||||||
if not assistant_parts or assistant_parts[-1].get("type") != "text" or visible not in str(assistant_parts[-1].get("text") or ""):
|
reviewer=ModelIdentity(review_provider.id, review_model.id),
|
||||||
_append_text_part(assistant_parts, visible + "\n\n")
|
):
|
||||||
elif name == "task_terminal":
|
sequence += 1
|
||||||
terminal = str(payload.get("lifecycle") or "")
|
decorated = {**payload, "taskId": task_id, "eventId": str(payload.get("eventId") or f"{task_id}_{sequence}_{name}"), "sequence": sequence, "timestamp": now_iso()}
|
||||||
if terminal == "failed":
|
_upsert_part(parts, {"type": "data-cad-progress", "id": decorated["eventId"], "data": _visible_progress(name, decorated)})
|
||||||
_upsert_data_part(assistant_parts, {"type": "data-cad-error", "id": decorated["eventId"], "data": {"stage": "generation", "message": str(payload.get("message") or "CAD 自主生成失败。")}})
|
if name == "task_terminal" and str(decorated.get("lifecycle") or "") == "failed":
|
||||||
elif name != "text_delta":
|
_upsert_part(parts, {"type": "data-cad-error", "id": f"{decorated['eventId']}_error", "data": {
|
||||||
_upsert_data_part(assistant_parts, {"type": "data-cad-progress", "id": decorated["eventId"], "data": _visible_progress(name, decorated)})
|
"stage": "generation",
|
||||||
|
"message": str(decorated.get("message") or "CAD autonomous generation failed."),
|
||||||
|
"tool": str(decorated.get("tool") or ""),
|
||||||
|
"fieldErrors": decorated.get("field_errors") if isinstance(decorated.get("field_errors"), list) else [],
|
||||||
|
}})
|
||||||
await queue.put((name, decorated))
|
await queue.put((name, decorated))
|
||||||
|
await dispatch_state_events()
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
self.store.finish_generation(task_id, lifecycle="failed", failure={"schema_version": "cad.autonomous-failure.v1", "stage": "worker", "message": str(error)})
|
sequence += 1
|
||||||
terminal = "failed"
|
terminal = {"taskId": task_id, "lifecycle": "failed", "code": "FAILED_INTERNAL", "message": str(error)[:1000], "eventId": f"{task_id}_{sequence}_task_terminal", "sequence": sequence, "timestamp": now_iso(), "userActionRequired": False}
|
||||||
payload = {"taskId": task_id, "lifecycle": "failed", "message": str(error)}
|
_upsert_part(parts, {"type": "data-cad-progress", "id": terminal["eventId"], "data": _visible_progress("task_terminal", terminal)})
|
||||||
error_id = f"{task_id}_{sequence + 1}_cad_error"
|
_upsert_part(parts, {"type": "data-cad-error", "id": f"{terminal['eventId']}_error", "data": {"stage": "generation", "message": terminal["message"], "fieldErrors": []}})
|
||||||
assistant_parts.append({"type": "data-cad-error", "id": error_id, "data": {"stage": "generation", "message": str(error)}})
|
await queue.put(("task_terminal", terminal))
|
||||||
await queue.put(("task_terminal", payload))
|
|
||||||
finally:
|
finally:
|
||||||
if terminal == "completed" and not assistant_parts:
|
self.store.append_conversation_message(conversation["conversation_id"], {"id": f"assistant_{secrets.token_hex(8)}", "role": "assistant", "parts": parts}, task_id)
|
||||||
assistant_parts.append({"type": "text", "text": "CAD 模型已完成。"})
|
|
||||||
self._persist_assistant(conversation["conversation_id"], assistant_id, assistant_parts, task_id)
|
|
||||||
self._autonomous_runs.pop(task_id, None)
|
self._autonomous_runs.pop(task_id, None)
|
||||||
await queue.put(None)
|
await queue.put(None)
|
||||||
|
|
||||||
self._autonomous_runs[task_id] = asyncio.create_task(consume(), name=f"autonomous-cdsl-{task_id}")
|
self._autonomous_runs[task_id] = asyncio.create_task(consume(), name=f"autonomous-cad-v3-{task_id}")
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
item = await asyncio.wait_for(queue.get(), timeout=15)
|
item = await asyncio.wait_for(queue.get(), timeout=15)
|
||||||
@@ -369,197 +384,66 @@ class AgentService:
|
|||||||
break
|
break
|
||||||
name, payload = item
|
name, payload = item
|
||||||
if name == "task_terminal" and str(payload.get("lifecycle") or "") == "failed":
|
if name == "task_terminal" and str(payload.get("lifecycle") or "") == "failed":
|
||||||
yield event("cad_error", {"stage": "generation", "message": str(payload.get("message") or "CAD 自主生成失败。")})
|
yield event("cad_error", {
|
||||||
elif name == "agent_thinking":
|
"stage": "generation",
|
||||||
message = str(payload.get("message") or "")
|
"message": str(payload.get("message") or "CAD autonomous generation failed."),
|
||||||
if message:
|
"tool": str(payload.get("tool") or ""),
|
||||||
yield event("text_delta", {"text": message + "\n\n"})
|
"fieldErrors": payload.get("field_errors") if isinstance(payload.get("field_errors"), list) else [],
|
||||||
else:
|
})
|
||||||
yield event(name, payload)
|
yield event(name, payload)
|
||||||
yield event("done", {})
|
yield event("done", {})
|
||||||
|
|
||||||
def _persist_assistant(self, conversation_id: str, assistant_id: str, parts: list[dict[str, Any]], task_id: str) -> None:
|
def _task_source_blocks(self, conversation: dict[str, Any], request: str) -> list[dict[str, Any]]:
|
||||||
self.store.append_conversation_message(
|
"""Freeze message paragraphs and attachment blocks before task creation."""
|
||||||
conversation_id,
|
blocks = [{"text": paragraph} for paragraph in re.split(r"\n\s*\n", request) if paragraph.strip()]
|
||||||
{"id": assistant_id, "role": "assistant", "parts": parts},
|
|
||||||
task_id or None,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _complete(
|
|
||||||
self,
|
|
||||||
messages: list[dict[str, Any]],
|
|
||||||
tools: list[dict[str, Any]],
|
|
||||||
provider: ProviderConfig,
|
|
||||||
model: ProviderModel,
|
|
||||||
required_tool_name: str | None = None,
|
|
||||||
*,
|
|
||||||
on_text_delta: Callable[[str], Awaitable[None]] | None = None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
if on_text_delta is None:
|
|
||||||
return await self._complete_once(messages, tools, provider, model, required_tool_name)
|
|
||||||
try:
|
|
||||||
return await self._complete_stream(messages, tools, provider, model, required_tool_name, on_text_delta)
|
|
||||||
except _StreamingUnsupported:
|
|
||||||
await on_text_delta("当前模型不支持工具流式,已切换为兼容模式。\n\n")
|
|
||||||
response = await self._complete_once(messages, tools, provider, model, required_tool_name)
|
|
||||||
content = str((((response.get("choices") or [{}])[0] or {}).get("message") or {}).get("content") or "")
|
|
||||||
if content:
|
|
||||||
await on_text_delta(content + "\n\n")
|
|
||||||
# Prevent the runner from emitting this same full response again.
|
|
||||||
choice = ((response.get("choices") or [{}])[0] or {}).get("message") or {}
|
|
||||||
return {"choices": [{"message": {**choice, "content": ""}}]}
|
|
||||||
|
|
||||||
async def _complete_stream(
|
|
||||||
self,
|
|
||||||
messages: list[dict[str, Any]],
|
|
||||||
tools: list[dict[str, Any]],
|
|
||||||
provider: ProviderConfig,
|
|
||||||
model: ProviderModel,
|
|
||||||
required_tool_name: str | None,
|
|
||||||
on_text_delta: Callable[[str], Awaitable[None]],
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
tool_choice: str | dict[str, Any] = "auto"
|
|
||||||
if required_tool_name:
|
|
||||||
tool_choice = {"type": "function", "function": {"name": required_tool_name}}
|
|
||||||
payload: dict[str, Any] = {
|
|
||||||
"model": model.id, "messages": messages, "tools": tools,
|
|
||||||
"tool_choice": tool_choice, "temperature": 0.1, "stream": True,
|
|
||||||
}
|
|
||||||
payload.update(provider.chat_completion_options)
|
|
||||||
headers = {"Authorization": f"Bearer {provider.api_key}", "Content-Type": "application/json"}
|
|
||||||
try:
|
|
||||||
async with httpx.AsyncClient(timeout=self.settings.llm_timeout_s) as client:
|
|
||||||
async with client.stream("POST", f"{provider.base_url}/chat/completions", headers=headers, json=payload) as response:
|
|
||||||
if response.status_code in {400, 404, 405, 415, 422}:
|
|
||||||
detail = (await response.aread()).decode(errors="replace")[:400]
|
|
||||||
raise _StreamingUnsupported(detail)
|
|
||||||
if response.status_code >= 400:
|
|
||||||
raise RuntimeError(f"LLM request failed ({response.status_code}): {(await response.aread()).decode(errors='replace')[:800]}")
|
|
||||||
if "text/event-stream" not in str(response.headers.get("content-type") or ""):
|
|
||||||
raise _StreamingUnsupported("provider returned a non-stream response")
|
|
||||||
tool_calls: dict[int, dict[str, Any]] = {}
|
|
||||||
async for line in response.aiter_lines():
|
|
||||||
if not line.startswith("data:"):
|
|
||||||
continue
|
|
||||||
raw = line[5:].strip()
|
|
||||||
if raw == "[DONE]":
|
|
||||||
break
|
|
||||||
try:
|
|
||||||
chunk = json.loads(raw)
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
continue
|
|
||||||
choices = chunk.get("choices") if isinstance(chunk, dict) else None
|
|
||||||
delta = choices[0].get("delta") if isinstance(choices, list) and choices and isinstance(choices[0], dict) else {}
|
|
||||||
if not isinstance(delta, dict):
|
|
||||||
continue
|
|
||||||
text = str(delta.get("content") or delta.get("reasoning_content") or "")
|
|
||||||
if text:
|
|
||||||
await on_text_delta(text)
|
|
||||||
for call in delta.get("tool_calls") or []:
|
|
||||||
if not isinstance(call, dict):
|
|
||||||
continue
|
|
||||||
index = int(call.get("index") or 0)
|
|
||||||
current = tool_calls.setdefault(index, {"id": str(call.get("id") or f"call_{index}"), "type": "function", "function": {"name": "", "arguments": ""}})
|
|
||||||
if call.get("id"):
|
|
||||||
current["id"] = str(call["id"])
|
|
||||||
function = call.get("function") if isinstance(call.get("function"), dict) else {}
|
|
||||||
if function.get("name"):
|
|
||||||
current["function"]["name"] += str(function["name"])
|
|
||||||
if function.get("arguments"):
|
|
||||||
current["function"]["arguments"] += str(function["arguments"])
|
|
||||||
except _StreamingUnsupported:
|
|
||||||
raise
|
|
||||||
except (httpx.HTTPError, asyncio.TimeoutError) as error:
|
|
||||||
raise RuntimeError(f"LLM streaming connection failed: {error}") from error
|
|
||||||
return {"choices": [{"message": {"content": "", "tool_calls": [tool_calls[key] for key in sorted(tool_calls)]}}]}
|
|
||||||
|
|
||||||
async def _complete_once(
|
|
||||||
self,
|
|
||||||
messages: list[dict[str, Any]],
|
|
||||||
tools: list[dict[str, Any]],
|
|
||||||
provider: ProviderConfig,
|
|
||||||
model: ProviderModel,
|
|
||||||
required_tool_name: str | None = None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
tool_choice: str | dict[str, Any] = "auto"
|
|
||||||
if required_tool_name:
|
|
||||||
tool_choice = {"type": "function", "function": {"name": required_tool_name}}
|
|
||||||
payload: dict[str, Any] = {
|
|
||||||
"model": model.id,
|
|
||||||
"messages": messages,
|
|
||||||
"tools": tools,
|
|
||||||
"tool_choice": tool_choice,
|
|
||||||
"temperature": 0.1,
|
|
||||||
}
|
|
||||||
payload.update(provider.chat_completion_options)
|
|
||||||
headers = {"Authorization": f"Bearer {provider.api_key}", "Content-Type": "application/json"}
|
|
||||||
# A transport failure is independent of authoring quality. Retry it
|
|
||||||
# here, before the durable runner can classify the task as failed.
|
|
||||||
# Keep the bound small: retries must not hide a broken provider or
|
|
||||||
# leave a locked task waiting indefinitely.
|
|
||||||
transport_error: Exception | None = None
|
|
||||||
response: httpx.Response | None = None
|
|
||||||
for attempt in range(3):
|
|
||||||
try:
|
|
||||||
async with httpx.AsyncClient(timeout=self.settings.llm_timeout_s) as client:
|
|
||||||
# ``httpx`` timeouts occasionally fail to interrupt a
|
|
||||||
# locally proxied socket. The coroutine deadline is the
|
|
||||||
# final authority so a durable CAD task cannot remain
|
|
||||||
# locked forever waiting for one author response.
|
|
||||||
response = await asyncio.wait_for(
|
|
||||||
client.post(f"{provider.base_url}/chat/completions", headers=headers, json=payload),
|
|
||||||
timeout=self.settings.llm_timeout_s,
|
|
||||||
)
|
|
||||||
if response.status_code == 400 and "thinking mode does not support this tool_choice" in response.text.lower():
|
|
||||||
retry_payload = dict(payload)
|
|
||||||
retry_payload.pop("tool_choice", None)
|
|
||||||
response = await asyncio.wait_for(
|
|
||||||
client.post(f"{provider.base_url}/chat/completions", headers=headers, json=retry_payload),
|
|
||||||
timeout=self.settings.llm_timeout_s,
|
|
||||||
)
|
|
||||||
break
|
|
||||||
except (httpx.HTTPError, asyncio.TimeoutError) as error:
|
|
||||||
transport_error = error
|
|
||||||
if attempt == 2:
|
|
||||||
raise RuntimeError(f"LLM connection failed after 3 attempts: {error}") from error
|
|
||||||
await asyncio.sleep(0.5 * (2**attempt))
|
|
||||||
if response is None:
|
|
||||||
raise RuntimeError(f"LLM connection failed after 3 attempts: {transport_error}")
|
|
||||||
if response.status_code >= 400:
|
|
||||||
raise RuntimeError(f"LLM request failed ({response.status_code}): {response.text[:800]}")
|
|
||||||
body = response.json()
|
|
||||||
if not isinstance(body, dict) or not isinstance(body.get("choices"), list) or not body["choices"]:
|
|
||||||
raise RuntimeError("LLM response contains no completion choices")
|
|
||||||
return body
|
|
||||||
|
|
||||||
def _attachment_message(self, conversation: dict[str, Any], model: ProviderModel) -> list[dict[str, Any]] | str:
|
|
||||||
attachments = conversation.get("attachments") or []
|
|
||||||
if not attachments:
|
|
||||||
return ""
|
|
||||||
conversation_id = str(conversation.get("conversation_id") or "")
|
conversation_id = str(conversation.get("conversation_id") or "")
|
||||||
if not conversation_id:
|
if not conversation_id:
|
||||||
raise ValueError("Conversation attachment has no conversation id")
|
raise ValueError("Conversation has no identifier")
|
||||||
content: list[dict[str, Any]] = [{"type": "text", "text": "These frozen attachments are part of the CAD request."}]
|
for attachment in conversation.get("attachments") or ():
|
||||||
for attachment in attachments:
|
|
||||||
if not isinstance(attachment, dict):
|
if not isinstance(attachment, dict):
|
||||||
continue
|
continue
|
||||||
if str(attachment.get("conversation_id") or "") != conversation_id:
|
if str(attachment.get("conversation_id") or "") != conversation_id:
|
||||||
raise ValueError("Conversation attachment metadata is invalid")
|
raise ValueError("Attachment does not belong to this conversation")
|
||||||
relative = str(attachment.get("path") or "")
|
attachment_id = str(attachment.get("id") or "")
|
||||||
if not relative:
|
relative_path = str(attachment.get("path") or "")
|
||||||
raise ValueError("Conversation attachment is missing its artifact path")
|
expected_digest = str(attachment.get("sha256") or "")
|
||||||
path = self.store.conversation_attachment_path(conversation_id, relative)
|
if not attachment_id or not relative_path or not re.fullmatch(r"[a-f0-9]{64}", expected_digest):
|
||||||
if not path.is_file():
|
raise ValueError("Attachment metadata is incomplete")
|
||||||
raise ValueError(f"Conversation attachment is missing: {attachment.get('name') or attachment.get('id')}")
|
binary_path = self.store.conversation_attachment_path(conversation_id, relative_path)
|
||||||
if str(attachment.get("kind") or "") == "image":
|
if not binary_path.is_file():
|
||||||
if not model.vision:
|
raise ValueError(f"Attachment is missing: {attachment.get('name') or attachment_id}")
|
||||||
raise ValueError("The selected author model does not support images. Select a vision-capable model for image attachments.")
|
if sha256(binary_path.read_bytes()).hexdigest() != expected_digest:
|
||||||
mime = str(attachment.get("mime") or "image/png")
|
raise ValueError(f"Attachment checksum mismatch: {attachment.get('name') or attachment_id}")
|
||||||
encoded = base64.b64encode(path.read_bytes()).decode("ascii")
|
kind = str(attachment.get("kind") or "")
|
||||||
content.append({"type": "image_url", "image_url": {"url": f"data:{mime};base64,{encoded}"}})
|
if kind == "document":
|
||||||
|
extracted_path = str(attachment.get("extracted_path") or "")
|
||||||
|
if not extracted_path:
|
||||||
|
raise ValueError(f"Attachment text is unavailable: {attachment.get('name') or attachment_id}")
|
||||||
|
text_path = self.store.conversation_attachment_path(conversation_id, extracted_path)
|
||||||
|
if not text_path.is_file():
|
||||||
|
raise ValueError(f"Attachment text is missing: {attachment.get('name') or attachment_id}")
|
||||||
|
text = text_path.read_text(encoding="utf-8", errors="replace").strip()
|
||||||
|
elif kind == "image":
|
||||||
|
# The binary is integrity-checked above. Do not claim that a
|
||||||
|
# text-only author has interpreted visual content; the source
|
||||||
|
# block is still a stable, reviewable attachment reference.
|
||||||
|
text = (
|
||||||
|
f"Visual attachment {attachment.get('name') or attachment_id} "
|
||||||
|
f"(SHA-256 {expected_digest}, MIME {attachment.get('mime') or 'image/*'}). "
|
||||||
|
"It is a visual reference and requires explicit visual verification."
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
extracted = str(attachment.get("extracted_path") or "")
|
raise ValueError(f"Unsupported attachment kind: {kind or 'unknown'}")
|
||||||
document_path = self.store.conversation_attachment_path(conversation_id, extracted) if extracted else path
|
if not text:
|
||||||
text = document_path.read_text(encoding="utf-8", errors="replace")[:24000] if document_path.is_file() else ""
|
raise ValueError(f"Attachment source is empty: {attachment.get('name') or attachment_id}")
|
||||||
content.append({"type": "text", "text": f"Document {attachment.get('name') or path.name}:\n{text}"})
|
blocks.append({
|
||||||
return content
|
"text": text,
|
||||||
|
"attachment": {
|
||||||
|
"attachment_id": attachment_id,
|
||||||
|
"name": str(attachment.get("name") or attachment_id),
|
||||||
|
"kind": kind,
|
||||||
|
"mime": str(attachment.get("mime") or "application/octet-stream"),
|
||||||
|
"sha256": expected_digest,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return blocks
|
||||||
|
|||||||
@@ -1,403 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import hashlib
|
|
||||||
import json
|
|
||||||
from copy import deepcopy
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from jsonschema import Draft202012Validator
|
|
||||||
|
|
||||||
from app.services.engine_service import feature_atomic_contract
|
|
||||||
|
|
||||||
|
|
||||||
class CanonicalFragmentError(ValueError):
|
|
||||||
def __init__(self, message: str, *, path: str = "fragment", code: str = "CDSL_SCHEMA_INVALID") -> None:
|
|
||||||
super().__init__(message)
|
|
||||||
self.path = path
|
|
||||||
self.code = code
|
|
||||||
|
|
||||||
|
|
||||||
def _point(size: int) -> dict[str, Any]:
|
|
||||||
return {
|
|
||||||
"type": "array",
|
|
||||||
"items": {"type": "number"},
|
|
||||||
"minItems": size,
|
|
||||||
"maxItems": size,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _workplane_schema() -> dict[str, Any]:
|
|
||||||
point3 = _point(3)
|
|
||||||
return {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"origin_mm": deepcopy(point3),
|
|
||||||
"x_dir": deepcopy(point3),
|
|
||||||
"normal": deepcopy(point3),
|
|
||||||
},
|
|
||||||
"required": ["origin_mm", "x_dir", "normal"],
|
|
||||||
"additionalProperties": False,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _analytic_segment_schema() -> dict[str, Any]:
|
|
||||||
point2 = _point(2)
|
|
||||||
return {
|
|
||||||
"oneOf": [
|
|
||||||
{
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"type": {"const": "line"},
|
|
||||||
"start": deepcopy(point2),
|
|
||||||
"end": deepcopy(point2),
|
|
||||||
},
|
|
||||||
"required": ["type", "start", "end"],
|
|
||||||
"additionalProperties": False,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"type": {"const": "arc"},
|
|
||||||
"start": deepcopy(point2),
|
|
||||||
"end": deepcopy(point2),
|
|
||||||
"center": deepcopy(point2),
|
|
||||||
"radius_mm": {"type": "number", "exclusiveMinimum": 0},
|
|
||||||
"clockwise": {"type": "boolean"},
|
|
||||||
},
|
|
||||||
"required": ["type", "start", "end", "center", "radius_mm"],
|
|
||||||
"additionalProperties": False,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"type": {"const": "circle"},
|
|
||||||
"center": deepcopy(point2),
|
|
||||||
"radius_mm": {"type": "number", "exclusiveMinimum": 0},
|
|
||||||
},
|
|
||||||
"required": ["type", "center", "radius_mm"],
|
|
||||||
"additionalProperties": False,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def canonical_profile_schema() -> dict[str, Any]:
|
|
||||||
point2 = _point(2)
|
|
||||||
segment = _analytic_segment_schema()
|
|
||||||
contour = {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"role": {"enum": ["outer", "inner"]},
|
|
||||||
"closed": {"const": True},
|
|
||||||
"segments": {"type": "array", "minItems": 1, "items": segment},
|
|
||||||
},
|
|
||||||
"required": ["role", "closed", "segments"],
|
|
||||||
"additionalProperties": False,
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
"oneOf": [
|
|
||||||
{
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"type": {"const": "circle"},
|
|
||||||
"center": deepcopy(point2),
|
|
||||||
"radius_mm": {"type": "number", "exclusiveMinimum": 0},
|
|
||||||
},
|
|
||||||
"required": ["type", "radius_mm"],
|
|
||||||
"additionalProperties": False,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"type": {"const": "polygon"},
|
|
||||||
"vertices": {"type": "array", "minItems": 3, "items": deepcopy(point2)},
|
|
||||||
},
|
|
||||||
"required": ["type", "vertices"],
|
|
||||||
"additionalProperties": False,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"type": {"const": "analytic_contours"},
|
|
||||||
"contours": {"type": "array", "minItems": 1, "maxItems": 2, "items": contour},
|
|
||||||
},
|
|
||||||
"required": ["type", "contours"],
|
|
||||||
"additionalProperties": False,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _end_condition_schema() -> dict[str, Any]:
|
|
||||||
return {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"type": {"type": "string", "minLength": 1},
|
|
||||||
"solidworks_code": {"type": "integer"},
|
|
||||||
},
|
|
||||||
"required": ["type", "solidworks_code"],
|
|
||||||
"additionalProperties": False,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _axis_schema() -> dict[str, Any]:
|
|
||||||
point3 = _point(3)
|
|
||||||
return {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {"origin_mm": deepcopy(point3), "direction": deepcopy(point3)},
|
|
||||||
"required": ["origin_mm", "direction"],
|
|
||||||
"additionalProperties": False,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _hole_positions_schema() -> dict[str, Any]:
|
|
||||||
return {
|
|
||||||
"type": "array",
|
|
||||||
"minItems": 1,
|
|
||||||
"items": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {"mm": _point(3)},
|
|
||||||
"required": ["mm"],
|
|
||||||
"additionalProperties": False,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _parameter_schema(atomic_id: str, contract: dict[str, Any]) -> dict[str, Any]:
|
|
||||||
positive = {"type": "number", "exclusiveMinimum": 0}
|
|
||||||
number = {"type": "number"}
|
|
||||||
integer = {"type": "integer", "minimum": 1}
|
|
||||||
point3 = _point(3)
|
|
||||||
properties: dict[str, Any] = {}
|
|
||||||
required = [name for name in contract["required_params"] if name not in {"host_face", "source_feature_ids", "mirror_plane"}]
|
|
||||||
|
|
||||||
if atomic_id.startswith("extrude_"):
|
|
||||||
properties = {
|
|
||||||
"distance_mm": deepcopy(number),
|
|
||||||
"reverse_distance_mm": deepcopy(number),
|
|
||||||
"reverse": {"type": "boolean"},
|
|
||||||
"end_condition": _end_condition_schema(),
|
|
||||||
"reverse_end_condition": _end_condition_schema(),
|
|
||||||
}
|
|
||||||
elif atomic_id.startswith("revolve_"):
|
|
||||||
properties = {
|
|
||||||
"angle_deg": {"type": "number", "exclusiveMinimum": 0, "maximum": 360},
|
|
||||||
"axis": _axis_schema(),
|
|
||||||
"reverse": {"type": "boolean"},
|
|
||||||
}
|
|
||||||
elif atomic_id.startswith("hole_"):
|
|
||||||
properties = {
|
|
||||||
"diameter_mm": deepcopy(positive),
|
|
||||||
"depth_mm": deepcopy(positive),
|
|
||||||
"positions": _hole_positions_schema(),
|
|
||||||
"drill_angle_rad": {"type": "number", "exclusiveMinimum": 0, "maximum": 3.141592653589793},
|
|
||||||
"countersink_diameter_mm": deepcopy(positive),
|
|
||||||
"countersink_angle_rad": {"type": "number", "exclusiveMinimum": 0, "maximum": 3.141592653589793},
|
|
||||||
"counterbore_diameter_mm": deepcopy(positive),
|
|
||||||
"counterbore_depth_mm": deepcopy(positive),
|
|
||||||
}
|
|
||||||
elif atomic_id == "sphere_add":
|
|
||||||
properties = {"radius_mm": deepcopy(positive), "center_mm": deepcopy(point3)}
|
|
||||||
elif atomic_id == "fillet":
|
|
||||||
properties = {"radius_mm": deepcopy(positive), "tangent_propagation": {"type": "boolean"}}
|
|
||||||
elif atomic_id == "chamfer":
|
|
||||||
properties = {
|
|
||||||
"distance_mm": deepcopy(positive),
|
|
||||||
"distance_2_mm": deepcopy(positive),
|
|
||||||
"angle_rad": {"type": "number", "exclusiveMinimum": 0, "maximum": 3.141592653589793},
|
|
||||||
}
|
|
||||||
elif atomic_id == "pattern_linear":
|
|
||||||
properties = {
|
|
||||||
"direction_1": deepcopy(point3),
|
|
||||||
"spacing_1_mm": deepcopy(positive),
|
|
||||||
"pattern_count_1": deepcopy(integer),
|
|
||||||
"direction_2": deepcopy(point3),
|
|
||||||
"spacing_2_mm": deepcopy(positive),
|
|
||||||
"pattern_count_2": deepcopy(integer),
|
|
||||||
}
|
|
||||||
elif atomic_id == "pattern_mirror":
|
|
||||||
properties = {}
|
|
||||||
elif atomic_id == "reference_axis":
|
|
||||||
properties = {"axis": _axis_schema()}
|
|
||||||
elif atomic_id == "reference_plane":
|
|
||||||
properties = {"plane": _workplane_schema()}
|
|
||||||
elif atomic_id == "hole_wizard":
|
|
||||||
required = ["hole_type", "diameter_mm", "depth_mm"]
|
|
||||||
properties = {
|
|
||||||
"hole_type": {"type": "string", "minLength": 1},
|
|
||||||
"diameter_mm": deepcopy(positive),
|
|
||||||
"depth_mm": deepcopy(positive),
|
|
||||||
"positions": _hole_positions_schema(),
|
|
||||||
"thread": {"type": "object"},
|
|
||||||
"countersink": {"type": "object"},
|
|
||||||
"counterbore": {"type": "object"},
|
|
||||||
}
|
|
||||||
else:
|
|
||||||
for name in [*contract["required_params"], *contract["optional_params"]]:
|
|
||||||
if name not in {"host_face", "source_feature_ids", "mirror_plane"}:
|
|
||||||
properties[name] = {}
|
|
||||||
|
|
||||||
allowed = set(required) | {
|
|
||||||
name for name in contract["optional_params"]
|
|
||||||
if name not in {"host_face", "source_feature_ids", "mirror_plane"}
|
|
||||||
}
|
|
||||||
properties = {name: value for name, value in properties.items() if name in allowed}
|
|
||||||
return {
|
|
||||||
"type": "object",
|
|
||||||
"properties": properties,
|
|
||||||
"required": required,
|
|
||||||
"additionalProperties": False,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _selector_limits(atomic_id: str, contract: dict[str, Any]) -> tuple[int, int] | None:
|
|
||||||
if atomic_id.startswith("hole_") or atomic_id == "hole_wizard":
|
|
||||||
return 1, 1
|
|
||||||
slot = contract.get("selector_slot")
|
|
||||||
if not isinstance(slot, dict):
|
|
||||||
return None
|
|
||||||
return int(slot.get("min_items") or 1), int(slot.get("max_items") or 1)
|
|
||||||
|
|
||||||
|
|
||||||
def build_operation_fragment_schema(
|
|
||||||
engine: Any,
|
|
||||||
atomic_id: str,
|
|
||||||
*,
|
|
||||||
correction: dict[str, Any] | None = None,
|
|
||||||
allow_multi_feature: bool = False,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
del correction
|
|
||||||
if allow_multi_feature:
|
|
||||||
raise ValueError("Canonical autonomous authoring currently permits exactly one feature")
|
|
||||||
contract = feature_atomic_contract(engine, atomic_id)
|
|
||||||
feature_properties: dict[str, Any] = {
|
|
||||||
"atomic_id": {"const": atomic_id},
|
|
||||||
"params": _parameter_schema(atomic_id, contract),
|
|
||||||
}
|
|
||||||
feature_required = ["atomic_id", "params"]
|
|
||||||
selector_limits = _selector_limits(atomic_id, contract)
|
|
||||||
if selector_limits is not None:
|
|
||||||
minimum, maximum = selector_limits
|
|
||||||
feature_properties["selector_tokens"] = {
|
|
||||||
"type": "array",
|
|
||||||
"items": {"type": "string", "minLength": 1},
|
|
||||||
"minItems": minimum,
|
|
||||||
"maxItems": maximum,
|
|
||||||
"uniqueItems": True,
|
|
||||||
}
|
|
||||||
feature_required.append("selector_tokens")
|
|
||||||
feature_schema = {
|
|
||||||
"type": "object",
|
|
||||||
"properties": feature_properties,
|
|
||||||
"required": feature_required,
|
|
||||||
"additionalProperties": False,
|
|
||||||
}
|
|
||||||
properties: dict[str, Any] = {"feature": feature_schema}
|
|
||||||
required = ["feature"]
|
|
||||||
if contract["requires_sketch"]:
|
|
||||||
properties["sketch"] = {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {"workplane": _workplane_schema(), "profile": canonical_profile_schema()},
|
|
||||||
"required": ["workplane", "profile"],
|
|
||||||
"additionalProperties": False,
|
|
||||||
}
|
|
||||||
required.insert(0, "sketch")
|
|
||||||
return {
|
|
||||||
"type": "object",
|
|
||||||
"properties": properties,
|
|
||||||
"required": required,
|
|
||||||
"additionalProperties": False,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def canonical_fragment_example(engine: Any, atomic_id: str) -> dict[str, Any]:
|
|
||||||
contract = feature_atomic_contract(engine, atomic_id)
|
|
||||||
feature: dict[str, Any] = {"atomic_id": atomic_id, "params": {}}
|
|
||||||
params = feature["params"]
|
|
||||||
if atomic_id.startswith("extrude_"):
|
|
||||||
params["distance_mm"] = 10
|
|
||||||
if "reverse_distance_mm" in contract["required_params"]:
|
|
||||||
params["reverse_distance_mm"] = 5
|
|
||||||
elif atomic_id.startswith("revolve_"):
|
|
||||||
params.update({"angle_deg": 360, "axis": {"origin_mm": [0, 0, 0], "direction": [0, 0, 1]}})
|
|
||||||
elif atomic_id.startswith("hole_"):
|
|
||||||
params.update({"diameter_mm": 10, "depth_mm": 20, "positions": [{"mm": [0, 0, 0]}]})
|
|
||||||
if atomic_id == "hole_countersink":
|
|
||||||
params.update({"countersink_diameter_mm": 16, "countersink_angle_rad": 1.5707963267948966})
|
|
||||||
if atomic_id == "hole_counterbore":
|
|
||||||
params.update({"counterbore_diameter_mm": 16, "counterbore_depth_mm": 4})
|
|
||||||
feature["selector_tokens"] = ["face-token-from-current-topology"]
|
|
||||||
elif atomic_id == "hole_wizard":
|
|
||||||
params.update({"hole_type": "simple", "diameter_mm": 10, "depth_mm": 20})
|
|
||||||
feature["selector_tokens"] = ["face-token-from-current-topology"]
|
|
||||||
elif atomic_id == "sphere_add":
|
|
||||||
params.update({"radius_mm": 10, "center_mm": [0, 0, 0]})
|
|
||||||
elif atomic_id == "fillet":
|
|
||||||
params["radius_mm"] = 0.5
|
|
||||||
feature["selector_tokens"] = ["edge-token-from-current-topology"]
|
|
||||||
elif atomic_id == "chamfer":
|
|
||||||
params["distance_mm"] = 0.5
|
|
||||||
feature["selector_tokens"] = ["edge-token-from-current-topology"]
|
|
||||||
elif atomic_id == "pattern_linear":
|
|
||||||
params.update({"direction_1": [1, 0, 0], "spacing_1_mm": 10, "pattern_count_1": 2})
|
|
||||||
elif atomic_id == "pattern_mirror":
|
|
||||||
feature["selector_tokens"] = ["plane-token-from-current-topology"]
|
|
||||||
elif atomic_id == "reference_axis":
|
|
||||||
params["axis"] = {"origin_mm": [0, 0, 0], "direction": [0, 0, 1]}
|
|
||||||
elif atomic_id == "reference_plane":
|
|
||||||
params["plane"] = {"origin_mm": [0, 0, 0], "x_dir": [1, 0, 0], "normal": [0, 0, 1]}
|
|
||||||
|
|
||||||
fragment: dict[str, Any] = {"feature": feature}
|
|
||||||
if contract["requires_sketch"]:
|
|
||||||
fragment["sketch"] = {
|
|
||||||
"workplane": {"origin_mm": [0, 0, 0], "x_dir": [1, 0, 0], "normal": [0, 0, 1]},
|
|
||||||
"profile": {"type": "polygon", "vertices": [[-10, -5], [10, -5], [10, 5], [-10, 5]]},
|
|
||||||
}
|
|
||||||
fragment = {"sketch": fragment["sketch"], "feature": feature}
|
|
||||||
return fragment
|
|
||||||
|
|
||||||
|
|
||||||
def operation_contract_hash(atomic_id: str, schema: dict[str, Any]) -> str:
|
|
||||||
encoded = json.dumps({"atomic_id": atomic_id, "schema": schema}, sort_keys=True, separators=(",", ":"))
|
|
||||||
return hashlib.sha256(encoded.encode("utf-8")).hexdigest()
|
|
||||||
|
|
||||||
|
|
||||||
def validate_canonical_fragment(engine: Any, fragment: dict[str, Any], *, expected_atomic_id: str = "") -> str:
|
|
||||||
feature = fragment.get("feature") if isinstance(fragment, dict) else None
|
|
||||||
atomic_id = str((feature or {}).get("atomic_id") or "") if isinstance(feature, dict) else ""
|
|
||||||
if expected_atomic_id and atomic_id and atomic_id != expected_atomic_id:
|
|
||||||
raise CanonicalFragmentError(
|
|
||||||
f"OPERATION_CONTRACT_MISMATCH: expected {expected_atomic_id}, received {atomic_id}",
|
|
||||||
path="fragment.feature.atomic_id",
|
|
||||||
code="OPERATION_CONTRACT_MISMATCH",
|
|
||||||
)
|
|
||||||
if not atomic_id:
|
|
||||||
raise CanonicalFragmentError(
|
|
||||||
"CDSL_SCHEMA_INVALID: canonical fragment requires fragment.feature.atomic_id",
|
|
||||||
path="fragment.feature.atomic_id",
|
|
||||||
)
|
|
||||||
schema = build_operation_fragment_schema(engine, atomic_id)
|
|
||||||
errors = sorted(Draft202012Validator(schema).iter_errors(fragment), key=lambda error: (list(error.absolute_path), error.message))
|
|
||||||
if errors:
|
|
||||||
error = errors[0]
|
|
||||||
path = "fragment" + "".join(
|
|
||||||
f"[{item}]" if isinstance(item, int) else f".{item}"
|
|
||||||
for item in error.absolute_path
|
|
||||||
)
|
|
||||||
raise CanonicalFragmentError(f"CDSL_SCHEMA_INVALID at {path}: {error.message}", path=path)
|
|
||||||
profile = ((fragment.get("sketch") or {}).get("profile") or {}) if isinstance(fragment, dict) else {}
|
|
||||||
if isinstance(profile, dict) and profile.get("type") == "analytic_contours":
|
|
||||||
roles = [str(item.get("role") or "") for item in profile.get("contours") or () if isinstance(item, dict)]
|
|
||||||
if roles.count("outer") != 1 or roles.count("inner") > 1:
|
|
||||||
raise CanonicalFragmentError(
|
|
||||||
"CDSL_PROFILE_MULTIPLE_OUTERS: analytic_contours requires exactly one outer contour and at most one inner contour",
|
|
||||||
path="fragment.sketch.profile.contours",
|
|
||||||
code="CDSL_PROFILE_MULTIPLE_OUTERS",
|
|
||||||
)
|
|
||||||
if roles.count("outer") == 0 and roles.count("inner"):
|
|
||||||
raise CanonicalFragmentError(
|
|
||||||
"CDSL_PROFILE_INNER_WITHOUT_OUTER: analytic_contours cannot contain an inner contour without an outer contour",
|
|
||||||
path="fragment.sketch.profile.contours",
|
|
||||||
code="CDSL_PROFILE_INNER_WITHOUT_OUTER",
|
|
||||||
)
|
|
||||||
return atomic_id
|
|
||||||
@@ -1,788 +0,0 @@
|
|||||||
"""Append-only CDSL materialisation for the autonomous authoring loop."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from copy import deepcopy
|
|
||||||
from hashlib import sha256
|
|
||||||
import json
|
|
||||||
import math
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
|
|
||||||
class CdslFragmentError(ValueError):
|
|
||||||
"""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:
|
|
||||||
value = cdsl or {"geometry": {"sketches": []}, "features": []}
|
|
||||||
return sha256(json.dumps(value, ensure_ascii=True, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest()
|
|
||||||
|
|
||||||
|
|
||||||
def materialize_fragment(base_cdsl: dict[str, Any] | None, fragment: dict[str, Any]) -> dict[str, Any]:
|
|
||||||
"""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",
|
|
||||||
"schema_version": "1.1.0",
|
|
||||||
"kind": "part",
|
|
||||||
"part_id": "agent_preflight",
|
|
||||||
"geometry": {"sketches": []},
|
|
||||||
"features": [],
|
|
||||||
}
|
|
||||||
else:
|
|
||||||
document = deepcopy(base_cdsl)
|
|
||||||
geometry = document.setdefault("geometry", {})
|
|
||||||
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 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 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 {}
|
|
||||||
values.append({
|
|
||||||
"consumer_node_id": node_id,
|
|
||||||
"consumer_feature_id": str(resolution.get("feature_id") or ""),
|
|
||||||
"source_snapshot_id": str(selector.get("snapshot_id") or snapshot_id),
|
|
||||||
"kind": str(selector.get("kind") or ""),
|
|
||||||
"owner_feature_id": str(selector.get("owner_feature_id") or ""),
|
|
||||||
"stable_id": str(selector.get("stable_id") or ""),
|
|
||||||
"geometry": deepcopy(selector.get("geometry") or {}),
|
|
||||||
"status": str(resolution.get("status") or ""),
|
|
||||||
"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 common feature-local sketch spellings without choosing geometry.
|
|
||||||
|
|
||||||
The public fragment grammar owns one ordered sketch list and one ordered
|
|
||||||
feature list. Tool-call models commonly emit either a direct feature with
|
|
||||||
a local ``sketch`` or a wrapper shaped as ``{sketch, feature}``. Both are
|
|
||||||
losslessly transformable when the fragment has no root sketch collection.
|
|
||||||
Sketchless features such as ``sphere_add`` and ``chamfer`` may be mixed in
|
|
||||||
the same batch; the materializer pairs only sketch-requiring features with
|
|
||||||
the lifted sketches.
|
|
||||||
"""
|
|
||||||
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]] = []
|
|
||||||
normalized_features: list[dict[str, Any]] = []
|
|
||||||
for index, item in enumerate(features):
|
|
||||||
wrapped = item.get("feature")
|
|
||||||
if wrapped is not None:
|
|
||||||
if not isinstance(wrapped, dict):
|
|
||||||
return
|
|
||||||
feature = deepcopy(wrapped)
|
|
||||||
if "selector_tokens" in item:
|
|
||||||
if "selector_tokens" in feature:
|
|
||||||
raise AutonomousFragmentError(
|
|
||||||
f"features[{index}] supplies selector_tokens both on the wrapper and feature"
|
|
||||||
)
|
|
||||||
feature["selector_tokens"] = deepcopy(item["selector_tokens"])
|
|
||||||
nested = item.get("sketches", item.get("sketch"))
|
|
||||||
fixes.append({"path": f"features[{index}]", "from": "{sketch,feature}", "to": "feature", "action": "unwrapped_equivalent"})
|
|
||||||
else:
|
|
||||||
feature = deepcopy(item)
|
|
||||||
nested = feature.get("sketches", feature.get("sketch"))
|
|
||||||
if isinstance(nested, dict):
|
|
||||||
sketches = [nested]
|
|
||||||
elif isinstance(nested, list):
|
|
||||||
sketches = nested
|
|
||||||
else:
|
|
||||||
normalized_features.append(feature)
|
|
||||||
continue
|
|
||||||
if len(sketches) != 1 or not isinstance(sketches[0], dict):
|
|
||||||
return
|
|
||||||
feature.pop("sketch", None)
|
|
||||||
feature.pop("sketches", None)
|
|
||||||
lifted.append(sketches[0])
|
|
||||||
normalized_features.append(feature)
|
|
||||||
fixes.append({"path": f"features[{index}]", "from": "feature-local sketch", "to": "sketches[]", "action": "lifted_equivalent"})
|
|
||||||
fragment["features"] = normalized_features
|
|
||||||
if lifted:
|
|
||||||
fragment["sketches"] = lifted
|
|
||||||
|
|
||||||
|
|
||||||
def _lift_param_embedded_sketches(fragment: dict[str, Any], *, fixes: list[dict[str, str]]) -> None:
|
|
||||||
"""Lift an exact legacy ``params.workplane/profile`` sketch spelling.
|
|
||||||
|
|
||||||
Some authors place an extrusion's complete sketch inside its params object.
|
|
||||||
The workplane and profile retain their meaning verbatim, so extracting them
|
|
||||||
is safe. Partial shapes remain invalid instead of being guessed.
|
|
||||||
"""
|
|
||||||
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 all(isinstance(item, dict) for item in features):
|
|
||||||
return
|
|
||||||
lifted: list[dict[str, Any]] = []
|
|
||||||
for index, feature in enumerate(features):
|
|
||||||
atomic_id = str(feature.get("atomic_id") or "")
|
|
||||||
params = feature.get("params")
|
|
||||||
if not atomic_id.startswith(("extrude_", "revolve_")) or not isinstance(params, dict):
|
|
||||||
continue
|
|
||||||
workplane = params.get("workplane")
|
|
||||||
profile = params.get("profile")
|
|
||||||
if workplane is None and profile is None:
|
|
||||||
continue
|
|
||||||
if not isinstance(workplane, dict) or not isinstance(profile, dict):
|
|
||||||
return
|
|
||||||
params.pop("workplane")
|
|
||||||
params.pop("profile")
|
|
||||||
lifted.append({"workplane": workplane, "profile": profile})
|
|
||||||
fixes.append({"path": f"features[{index}].params", "from": "workplane/profile", "to": "sketches[]", "action": "lifted_equivalent"})
|
|
||||||
if lifted:
|
|
||||||
fragment["sketches"] = lifted
|
|
||||||
|
|
||||||
|
|
||||||
def _set_reverse_from_direction(params: dict[str, Any], *, reverse: bool, location: str, fixes: list[dict[str, str]]) -> None:
|
|
||||||
if "reverse" in params and params["reverse"] is not reverse:
|
|
||||||
raise AutonomousFragmentError(
|
|
||||||
f"CONFLICTING_PARAMETER_ALIASES at {location}: direction conflicts with reverse"
|
|
||||||
)
|
|
||||||
params["reverse"] = reverse
|
|
||||||
params.pop("direction", None)
|
|
||||||
fixes.append({"path": location, "from": "direction", "to": "reverse", "action": "normalized_equivalent"})
|
|
||||||
|
|
||||||
|
|
||||||
def _normalize_extrude_direction(
|
|
||||||
params: dict[str, Any],
|
|
||||||
sketch: dict[str, Any] | None,
|
|
||||||
*,
|
|
||||||
location: str,
|
|
||||||
fixes: list[dict[str, str]],
|
|
||||||
) -> None:
|
|
||||||
"""Accept an extrusion direction only when it exactly restates the sketch."""
|
|
||||||
direction = params.get("direction")
|
|
||||||
if direction is None:
|
|
||||||
return
|
|
||||||
if isinstance(direction, str):
|
|
||||||
normalized = direction.strip().lower()
|
|
||||||
if normalized in {"negative", "reverse", "-normal"}:
|
|
||||||
_set_reverse_from_direction(params, reverse=True, location=location, fixes=fixes)
|
|
||||||
elif normalized in {"positive", "forward", "+normal"}:
|
|
||||||
_set_reverse_from_direction(params, reverse=False, location=location, fixes=fixes)
|
|
||||||
return
|
|
||||||
workplane = sketch.get("workplane") if isinstance(sketch, dict) else None
|
|
||||||
normal = workplane.get("normal") if isinstance(workplane, dict) else None
|
|
||||||
if (
|
|
||||||
not isinstance(direction, list)
|
|
||||||
or not isinstance(normal, list)
|
|
||||||
or len(direction) != 3
|
|
||||||
or len(normal) != 3
|
|
||||||
or not all(isinstance(value, (int, float)) and not isinstance(value, bool) for value in [*direction, *normal])
|
|
||||||
):
|
|
||||||
return
|
|
||||||
direction_norm = math.sqrt(sum(float(value) ** 2 for value in direction))
|
|
||||||
normal_norm = math.sqrt(sum(float(value) ** 2 for value in normal))
|
|
||||||
if direction_norm == 0 or normal_norm == 0:
|
|
||||||
return
|
|
||||||
cosine = sum(float(direction[index]) * float(normal[index]) for index in range(3)) / (direction_norm * normal_norm)
|
|
||||||
if math.isclose(cosine, 1.0, abs_tol=1e-9):
|
|
||||||
params.pop("direction")
|
|
||||||
fixes.append({"path": location, "from": "direction", "to": "workplane.normal", "action": "deduplicated_equivalent"})
|
|
||||||
elif math.isclose(cosine, -1.0, abs_tol=1e-9):
|
|
||||||
_set_reverse_from_direction(params, reverse=True, location=location, fixes=fixes)
|
|
||||||
|
|
||||||
|
|
||||||
def _normalize_angle_radians(params: dict[str, Any], *, location: str, fixes: list[dict[str, str]]) -> None:
|
|
||||||
"""Convert the explicitly unit-labelled angle_rad alias to angle_deg."""
|
|
||||||
if "angle_rad" not in params:
|
|
||||||
return
|
|
||||||
radians = params["angle_rad"]
|
|
||||||
if not isinstance(radians, (int, float)) or isinstance(radians, bool) or not math.isfinite(float(radians)):
|
|
||||||
return
|
|
||||||
degrees = float(radians) * 180.0 / math.pi
|
|
||||||
if "angle_deg" in params:
|
|
||||||
supplied = params["angle_deg"]
|
|
||||||
if not isinstance(supplied, (int, float)) or isinstance(supplied, bool) or not math.isclose(float(supplied), degrees, rel_tol=0.0, abs_tol=1e-9):
|
|
||||||
raise AutonomousFragmentError(
|
|
||||||
f"CONFLICTING_PARAMETER_ALIASES at {location}: angle_rad conflicts with angle_deg"
|
|
||||||
)
|
|
||||||
params.pop("angle_rad")
|
|
||||||
fixes.append({"path": location, "from": "angle_rad", "to": "angle_deg", "action": "deduplicated_equivalent"})
|
|
||||||
return
|
|
||||||
params["angle_deg"] = degrees
|
|
||||||
params.pop("angle_rad")
|
|
||||||
fixes.append({"path": location, "from": "angle_rad", "to": "angle_deg", "action": "converted_unit"})
|
|
||||||
|
|
||||||
|
|
||||||
def _normalize_concentric_circle_contours(profile: dict[str, Any], *, location: str, fixes: list[dict[str, str]]) -> None:
|
|
||||||
"""Expand a common two-circle annulus shorthand into analytic contours."""
|
|
||||||
if profile.get("type") != "analytic_contours":
|
|
||||||
return
|
|
||||||
contours = profile.get("contours")
|
|
||||||
if not isinstance(contours, list) or len(contours) != 2 or not all(isinstance(item, dict) for item in contours):
|
|
||||||
return
|
|
||||||
if not all(item.get("type") == "circle" and isinstance(item.get("center"), list) and len(item["center"]) == 2 for item in contours):
|
|
||||||
return
|
|
||||||
if contours[0]["center"] != contours[1]["center"]:
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
ordered = sorted(contours, key=lambda item: float(item["radius_mm"]), reverse=True)
|
|
||||||
except (KeyError, TypeError, ValueError):
|
|
||||||
return
|
|
||||||
if float(ordered[0]["radius_mm"]) <= float(ordered[1]["radius_mm"]):
|
|
||||||
return
|
|
||||||
profile["contours"] = [
|
|
||||||
{"role": role, "closed": True, "segments": [{"type": "circle", "center": item["center"], "radius_mm": item["radius_mm"]}]}
|
|
||||||
for role, item in zip(("outer", "inner"), ordered)
|
|
||||||
]
|
|
||||||
fixes.append({"path": location, "from": "circle contour shorthand", "to": "analytic_contours.segments", "action": "expanded_equivalent"})
|
|
||||||
|
|
||||||
|
|
||||||
def _finite_vector3(value: Any) -> tuple[float, float, float] | None:
|
|
||||||
"""Return a finite numeric vector when the author supplied one."""
|
|
||||||
if (
|
|
||||||
not isinstance(value, list)
|
|
||||||
or len(value) != 3
|
|
||||||
or not all(isinstance(component, (int, float)) and not isinstance(component, bool) for component in value)
|
|
||||||
):
|
|
||||||
return None
|
|
||||||
result = tuple(float(component) for component in value)
|
|
||||||
return result if all(math.isfinite(component) for component in result) else None
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_revolve_axis_in_sketch_plane(
|
|
||||||
atomic_id: str,
|
|
||||||
params: dict[str, Any],
|
|
||||||
sketch: dict[str, Any],
|
|
||||||
) -> None:
|
|
||||||
"""Reject a revolve axis that cannot be a construction line of its sketch.
|
|
||||||
|
|
||||||
A solid revolve is defined around an axis in the source sketch plane.
|
|
||||||
Letting an out-of-plane axis reach OCC can produce degenerate BReps that
|
|
||||||
fail much later during tessellation, so enforce this geometric invariant
|
|
||||||
before candidate staging. Malformed vectors are left to CDSL schema
|
|
||||||
validation, which can report their field-level shape.
|
|
||||||
"""
|
|
||||||
axis = params.get("axis")
|
|
||||||
workplane = sketch.get("workplane") if isinstance(sketch.get("workplane"), dict) else None
|
|
||||||
if not isinstance(axis, dict) or not isinstance(workplane, dict):
|
|
||||||
return
|
|
||||||
axis_origin = _finite_vector3(axis.get("origin_mm"))
|
|
||||||
axis_direction = _finite_vector3(axis.get("direction"))
|
|
||||||
plane_origin = _finite_vector3(workplane.get("origin_mm"))
|
|
||||||
plane_normal = _finite_vector3(workplane.get("normal"))
|
|
||||||
if None in {axis_origin, axis_direction, plane_origin, plane_normal}:
|
|
||||||
return
|
|
||||||
assert axis_origin is not None and axis_direction is not None and plane_origin is not None and plane_normal is not None
|
|
||||||
direction_length = math.sqrt(sum(component * component for component in axis_direction))
|
|
||||||
normal_length = math.sqrt(sum(component * component for component in plane_normal))
|
|
||||||
if direction_length == 0 or normal_length == 0:
|
|
||||||
return
|
|
||||||
direction_normal_dot = abs(sum(axis_direction[index] * plane_normal[index] for index in range(3)) / (direction_length * normal_length))
|
|
||||||
if direction_normal_dot > 1e-7:
|
|
||||||
raise AutonomousFragmentError(
|
|
||||||
"REVOLVE_AXIS_NOT_IN_SKETCH_PLANE: "
|
|
||||||
f"{atomic_id} params.axis.direction must be parallel to sketch.workplane; "
|
|
||||||
f"abs(dot(axis_direction, plane_normal))={direction_normal_dot:.3g}"
|
|
||||||
)
|
|
||||||
origin_plane_offset = abs(sum((axis_origin[index] - plane_origin[index]) * plane_normal[index] for index in range(3)) / normal_length)
|
|
||||||
if origin_plane_offset > 1e-6:
|
|
||||||
raise AutonomousFragmentError(
|
|
||||||
"REVOLVE_AXIS_NOT_IN_SKETCH_PLANE: "
|
|
||||||
f"{atomic_id} params.axis.origin_mm must lie in sketch.workplane; "
|
|
||||||
f"plane_offset_mm={origin_plane_offset:.3g}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
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)
|
|
||||||
_lift_param_embedded_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,
|
|
||||||
)
|
|
||||||
_normalize_angle_radians(params, 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))
|
|
||||||
sketch_feature_index = 0
|
|
||||||
for current_feature, feature_location in feature_values:
|
|
||||||
atomic_id = str(current_feature.get("atomic_id") or "")
|
|
||||||
if not atomic_id.startswith(("extrude_", "revolve_")):
|
|
||||||
continue
|
|
||||||
current_sketch = sketch_values[sketch_feature_index][0] if sketch_feature_index < len(sketch_values) else None
|
|
||||||
sketch_feature_index += 1
|
|
||||||
if atomic_id.startswith("extrude_"):
|
|
||||||
params = current_feature.get("params")
|
|
||||||
if isinstance(params, dict):
|
|
||||||
_normalize_extrude_direction(
|
|
||||||
params,
|
|
||||||
sketch=current_sketch,
|
|
||||||
location=f"{feature_location}.params",
|
|
||||||
fixes=fixes,
|
|
||||||
)
|
|
||||||
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)
|
|
||||||
if isinstance(profile, dict):
|
|
||||||
_normalize_concentric_circle_contours(profile, location=f"{location}.profile", fixes=fixes)
|
|
||||||
# Earlier versions advertised sphere_add as sketch-backed even though its
|
|
||||||
# executor has always used only radius_mm and center_mm. Preserve that
|
|
||||||
# single-feature spelling without keeping an unused locator sketch in the
|
|
||||||
# immutable CDSL document.
|
|
||||||
if (
|
|
||||||
len(feature_values) == 1
|
|
||||||
and str(feature_values[0][0].get("atomic_id") or "") == "sphere_add"
|
|
||||||
and len(sketch_values) == 1
|
|
||||||
):
|
|
||||||
normalized.pop("sketch", None)
|
|
||||||
normalized.pop("sketches", None)
|
|
||||||
normalized.pop("add_sketches", None)
|
|
||||||
fixes.append({"path": "sketch", "from": "sphere locator sketch", "to": "none", "action": "dropped_unused_legacy_locator"})
|
|
||||||
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,
|
|
||||||
source: str = "legacy_restore",
|
|
||||||
allow_legacy_aliases: bool = True,
|
|
||||||
expected_atomic_id: str = "",
|
|
||||||
) -> 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
|
|
||||||
|
|
||||||
from app.services.cdsl_authoring_schema import CanonicalFragmentError, validate_canonical_fragment
|
|
||||||
|
|
||||||
normalized_fragment, compatibility_fixes = normalize_autonomous_fragment(fragment)
|
|
||||||
if not allow_legacy_aliases:
|
|
||||||
if compatibility_fixes:
|
|
||||||
first = compatibility_fixes[0]
|
|
||||||
location = str(first.get("path") or "fragment")
|
|
||||||
legacy = str(first.get("from") or "legacy field")
|
|
||||||
canonical = str(first.get("to") or "canonical field")
|
|
||||||
raise AutonomousFragmentError(
|
|
||||||
f"CDSL_CANONICAL_FORMAT_REQUIRED at fragment.{location}: "
|
|
||||||
f"{legacy} is a legacy spelling; use {canonical}"
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
validate_canonical_fragment(engine, fragment, expected_atomic_id=expected_atomic_id)
|
|
||||||
except CanonicalFragmentError as error:
|
|
||||||
raise AutonomousFragmentError(str(error)) from error
|
|
||||||
normalized_fragment = deepcopy(fragment)
|
|
||||||
compatibility_fixes = []
|
|
||||||
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]] = []
|
|
||||||
|
|
||||||
sketch_index = 0
|
|
||||||
for source_feature in features:
|
|
||||||
# Materialization injects server-owned ids, host faces and pattern
|
|
||||||
# sources. Work on a private copy so the original tool-call fragment
|
|
||||||
# remains intact in audit records and diagnostic replacement cards.
|
|
||||||
source_feature = deepcopy(source_feature)
|
|
||||||
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("revolve_") and params.get("angle_deg") is None:
|
|
||||||
# A shared batch axis is deliberately limited to the axis. A
|
|
||||||
# default revolution angle would silently turn valid partial
|
|
||||||
# revolves into a different solid, so it remains author-owned.
|
|
||||||
raise AutonomousFragmentError(
|
|
||||||
f"{atomic_id} requires params.angle_deg; revolve_axis (including shared_revolve_axis) "
|
|
||||||
"supplies only params.axis. Declare an explicit angle in degrees."
|
|
||||||
)
|
|
||||||
token_backed_param = atomic_id.startswith("hole_") or atomic_id == "hole_wizard"
|
|
||||||
tokens = source_feature.pop("selector_tokens", [])
|
|
||||||
token_list_is_valid = (
|
|
||||||
isinstance(tokens, list)
|
|
||||||
and all(isinstance(token, str) for token in tokens)
|
|
||||||
and len(set(tokens)) == len(tokens)
|
|
||||||
)
|
|
||||||
if not token_list_is_valid and not token_backed_param:
|
|
||||||
raise AutonomousFragmentError("selector_tokens must be a unique array of opaque tokens")
|
|
||||||
if not token_list_is_valid:
|
|
||||||
tokens = []
|
|
||||||
selected: list[dict[str, Any]] = []
|
|
||||||
invalid_tokens: list[str] = []
|
|
||||||
for token in tokens:
|
|
||||||
candidate = selector_tokens.get(token)
|
|
||||||
if candidate is None:
|
|
||||||
if token_backed_param:
|
|
||||||
invalid_tokens.append(token)
|
|
||||||
continue
|
|
||||||
raise AutonomousFragmentError("TOPOLOGY_TOKEN_INVALID: selector token is not from the active snapshot")
|
|
||||||
selected.append(deepcopy(candidate["selector"]))
|
|
||||||
slot = contract.get("selector_slot")
|
|
||||||
if token_backed_param:
|
|
||||||
# Report all author-correctable hole errors at once. A hole is
|
|
||||||
# topology-sensitive, so its host face remains server-owned and
|
|
||||||
# must be injected from one current face token.
|
|
||||||
issues: list[str] = []
|
|
||||||
author_params = (set(contract["required_params"]) | set(contract["optional_params"])) - {"host_face"}
|
|
||||||
if "host_face" in params:
|
|
||||||
issues.append("params.host_face is server-owned; use selector_tokens")
|
|
||||||
missing = [name for name in contract["required_params"] if name != "host_face" and name not in params]
|
|
||||||
if missing:
|
|
||||||
issues.append("missing params: " + ", ".join(missing))
|
|
||||||
unexpected = sorted(name for name in params if name not in author_params and name != "host_face")
|
|
||||||
if unexpected:
|
|
||||||
issues.append("unsupported params: " + ", ".join(unexpected))
|
|
||||||
if not token_list_is_valid:
|
|
||||||
issues.append("selector_tokens must be a unique array of opaque tokens")
|
|
||||||
if invalid_tokens:
|
|
||||||
issues.append("TOPOLOGY_TOKEN_INVALID: selector token is not from the active snapshot")
|
|
||||||
if len(tokens) != 1 or len(selected) != 1 or str((selected[0] if selected else {}).get("kind") or "") != "face":
|
|
||||||
issues.append(f"{atomic_id} requires exactly one face selector token for its host face")
|
|
||||||
if issues:
|
|
||||||
raise AutonomousFragmentError("HOLE_FRAGMENT_INVALID: " + "; ".join(issues))
|
|
||||||
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 token_backed_param:
|
|
||||||
# Hole token validation above deliberately aggregates every
|
|
||||||
# actionable error before this materialization boundary.
|
|
||||||
pass
|
|
||||||
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 sketch_index >= len(sketches):
|
|
||||||
raise AutonomousFragmentError(f"{atomic_id} requires one new sketch in the same fragment")
|
|
||||||
sketch = sketches[sketch_index]
|
|
||||||
sketch_index += 1
|
|
||||||
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
|
|
||||||
if atomic_id.startswith("revolve_"):
|
|
||||||
_validate_revolve_axis_in_sketch_plane(atomic_id, params, sketch)
|
|
||||||
materialized_sketches.append(sketch)
|
|
||||||
output["sketch_id"] = sketch_id
|
|
||||||
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 sketch_index != len(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,
|
|
||||||
"compatibility_fix_count": len(compatibility_fixes),
|
|
||||||
"legacy_input": source != "tool_call" or bool(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", [])],
|
|
||||||
}
|
|
||||||
@@ -46,8 +46,8 @@ def _read_schema_document(path_value: str, modified_ns: int) -> dict[str, Any]:
|
|||||||
schema = json.loads(schema_path.read_text(encoding="utf-8"))
|
schema = json.loads(schema_path.read_text(encoding="utf-8"))
|
||||||
except (OSError, json.JSONDecodeError) as error:
|
except (OSError, json.JSONDecodeError) as error:
|
||||||
raise RuntimeError("The local engine schema document is unavailable or invalid") from error
|
raise RuntimeError("The local engine schema document is unavailable or invalid") from error
|
||||||
if not isinstance(schema, dict) or not isinstance(schema.get("feature_atomic_ids"), dict):
|
if not isinstance(schema, dict) or not isinstance(schema.get("operation_contracts"), dict):
|
||||||
raise RuntimeError("The local engine schema has no feature_atomic_ids contract")
|
raise RuntimeError("The local engine schema has no v3 operation contract registry")
|
||||||
return schema
|
return schema
|
||||||
|
|
||||||
|
|
||||||
@@ -60,31 +60,6 @@ def _engine_schema(engine: Any) -> dict[str, Any]:
|
|||||||
return _read_schema_document(str(schema_path), modified_ns)
|
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)
|
@lru_cache(maxsize=16)
|
||||||
def _cdsl_validator(path_value: str, modified_ns: int) -> Draft202012Validator:
|
def _cdsl_validator(path_value: str, modified_ns: int) -> Draft202012Validator:
|
||||||
"""Compile the JSON Schema once per runtime schema revision."""
|
"""Compile the JSON Schema once per runtime schema revision."""
|
||||||
@@ -152,11 +127,11 @@ def validate_cdsl(cdsl: dict[str, Any], engine: Any) -> None:
|
|||||||
raise ValueError("CDSL requires a feature list and a geometry.sketches array")
|
raise ValueError("CDSL requires a feature list and a geometry.sketches array")
|
||||||
sketch_ids = {str(sketch.get("id")) for sketch in sketches}
|
sketch_ids = {str(sketch.get("id")) for sketch in sketches}
|
||||||
semantic_contract = _engine_schema(engine)
|
semantic_contract = _engine_schema(engine)
|
||||||
atomic_contracts = semantic_contract["feature_atomic_ids"]
|
try:
|
||||||
declared_atomic_ids = {
|
atomic_contracts = engine.materialized_feature_contracts(semantic_contract)
|
||||||
str(atomic_id)
|
except (AttributeError, ValueError) as error:
|
||||||
for atomic_id in semantic_contract.get("runtime_supported_atomic_ids", atomic_contracts)
|
raise RuntimeError("The local engine operation contract registry is invalid") from error
|
||||||
}
|
declared_atomic_ids = {str(atomic_id) for atomic_id in atomic_contracts}
|
||||||
registered_atomic_ids = {str(atomic_id) for atomic_id in getattr(engine, "SUPPORTED_ATOMIC_IDS", ())}
|
registered_atomic_ids = {str(atomic_id) for atomic_id in getattr(engine, "SUPPORTED_ATOMIC_IDS", ())}
|
||||||
supported_atomic_ids = sorted(declared_atomic_ids & registered_atomic_ids)
|
supported_atomic_ids = sorted(declared_atomic_ids & registered_atomic_ids)
|
||||||
feature_ids: set[str] = set()
|
feature_ids: set[str] = set()
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
|
"""Conversation and attachment storage for the v3 delivery boundary.
|
||||||
|
|
||||||
|
CAD task state deliberately does not live here. Protocol v3 owns mutable task
|
||||||
|
state in ``SqliteTaskRepository`` and immutable task artifacts in
|
||||||
|
``FileArtifactStore``.
|
||||||
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import re
|
|
||||||
import secrets
|
|
||||||
from copy import deepcopy
|
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
import secrets
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from app.settings import Settings
|
from app.settings import Settings
|
||||||
@@ -13,17 +19,12 @@ from app.settings import Settings
|
|||||||
|
|
||||||
TASK_ID = re.compile(r"^cad_[a-z0-9]{12}$")
|
TASK_ID = re.compile(r"^cad_[a-z0-9]{12}$")
|
||||||
CONVERSATION_ID = re.compile(r"^conv_[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:
|
def now_iso() -> str:
|
||||||
return datetime.now(timezone.utc).isoformat()
|
return datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
|
||||||
def new_id(prefix: str) -> str:
|
|
||||||
return f"{prefix}_{secrets.token_hex(6)}"
|
|
||||||
|
|
||||||
|
|
||||||
def safe_task_id(task_id: str) -> str:
|
def safe_task_id(task_id: str) -> str:
|
||||||
value = str(task_id or "").strip()
|
value = str(task_id or "").strip()
|
||||||
if not TASK_ID.fullmatch(value):
|
if not TASK_ID.fullmatch(value):
|
||||||
@@ -38,117 +39,89 @@ def safe_conversation_id(conversation_id: str) -> str:
|
|||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
def safe_relative_path(value: str) -> str:
|
def _safe_relative_path(value: str) -> str:
|
||||||
path = Path(str(value or ""))
|
path = Path(str(value or ""))
|
||||||
if not value or path.is_absolute() or ".." in path.parts:
|
if not value or path.is_absolute() or ".." in path.parts:
|
||||||
raise ValueError("Invalid artifact path")
|
raise ValueError("Invalid conversation artifact path")
|
||||||
return path.as_posix()
|
return path.as_posix()
|
||||||
|
|
||||||
|
|
||||||
def write_json(path: Path, payload: Any) -> None:
|
def _write_json(path: Path, payload: Any) -> None:
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
def read_json(path: Path, fallback: Any = None) -> Any:
|
def _read_json(path: Path) -> dict[str, Any] | None:
|
||||||
if not path.is_file():
|
if not path.is_file():
|
||||||
return fallback
|
return None
|
||||||
return json.loads(path.read_text(encoding="utf-8"))
|
value = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
return value if isinstance(value, dict) else None
|
||||||
|
|
||||||
|
|
||||||
class WorkspaceStore:
|
class WorkspaceStore:
|
||||||
|
"""A conversation store, intentionally not a CAD task repository."""
|
||||||
|
|
||||||
def __init__(self, settings: Settings) -> None:
|
def __init__(self, settings: Settings) -> None:
|
||||||
self.settings = settings
|
self.settings = settings
|
||||||
self.settings.task_root.mkdir(parents=True, exist_ok=True)
|
|
||||||
self.settings.conversation_root.mkdir(parents=True, exist_ok=True)
|
self.settings.conversation_root.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
def task_dir(self, task_id: str) -> Path:
|
|
||||||
return self.settings.task_root / safe_task_id(task_id)
|
|
||||||
|
|
||||||
def task_path(self, task_id: str) -> Path:
|
|
||||||
return self.task_dir(task_id) / "task.json"
|
|
||||||
|
|
||||||
def conversation_dir(self, conversation_id: str) -> Path:
|
def conversation_dir(self, conversation_id: str) -> Path:
|
||||||
return self.settings.conversation_root / safe_conversation_id(conversation_id)
|
return self.settings.conversation_root / safe_conversation_id(conversation_id)
|
||||||
|
|
||||||
def conversation_path(self, conversation_id: str) -> Path:
|
def conversation_path(self, conversation_id: str) -> Path:
|
||||||
return self.conversation_dir(conversation_id) / "conversation.json"
|
return self.conversation_dir(conversation_id) / "conversation.json"
|
||||||
|
|
||||||
def write_tool_call_diagnostic(self, conversation_id: str, payload: dict[str, Any]) -> str:
|
def ensure_conversation(self, conversation_id: str | None, current_task_id: str | None = None) -> dict[str, Any]:
|
||||||
"""Persist one failed model tool call without creating a CAD revision."""
|
conversation = safe_conversation_id(conversation_id) if conversation_id else f"conv_{secrets.token_hex(6)}"
|
||||||
conversation = safe_conversation_id(conversation_id)
|
path = self.conversation_path(conversation)
|
||||||
relative = Path("diagnostics") / f"tool_call_{secrets.token_hex(8)}.json"
|
current = _read_json(path)
|
||||||
path = self.conversation_dir(conversation) / relative
|
if current is not None:
|
||||||
write_json(path, payload)
|
if current_task_id and current.get("current_task_id") != safe_task_id(current_task_id):
|
||||||
return (Path(conversation) / relative).as_posix()
|
|
||||||
|
|
||||||
def write_cdsl_attempt(self, conversation_id: str, cdsl: Any, iteration: int) -> str:
|
|
||||||
"""Retain a parsed model candidate before validation or execution."""
|
|
||||||
conversation = safe_conversation_id(conversation_id)
|
|
||||||
relative = Path("diagnostics") / f"cdsl_attempt_{iteration:02d}_{secrets.token_hex(8)}.json"
|
|
||||||
path = self.conversation_dir(conversation) / relative
|
|
||||||
write_json(path, cdsl)
|
|
||||||
return (Path(conversation) / relative).as_posix()
|
|
||||||
|
|
||||||
def write_cdsl_validation_diagnostic(self, conversation_id: str, payload: dict[str, Any]) -> str:
|
|
||||||
"""Persist the reason a retained CDSL candidate was rejected."""
|
|
||||||
conversation = safe_conversation_id(conversation_id)
|
|
||||||
relative = Path("diagnostics") / f"cdsl_validation_{secrets.token_hex(8)}.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,
|
|
||||||
current_task_id: str | None = None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
cid = safe_conversation_id(conversation_id) if conversation_id else new_id("conv")
|
|
||||||
path = self.conversation_path(cid)
|
|
||||||
current = read_json(path)
|
|
||||||
if current:
|
|
||||||
changed = False
|
|
||||||
if current_task_id:
|
|
||||||
current["current_task_id"] = safe_task_id(current_task_id)
|
current["current_task_id"] = safe_task_id(current_task_id)
|
||||||
changed = True
|
|
||||||
if changed:
|
|
||||||
current["updated_at"] = now_iso()
|
current["updated_at"] = now_iso()
|
||||||
write_json(path, current)
|
_write_json(path, current)
|
||||||
return current
|
return current
|
||||||
record = {
|
record = {
|
||||||
"schema_version": "1.2",
|
"schema_version": "3.0",
|
||||||
"conversation_id": cid,
|
"conversation_id": conversation,
|
||||||
"created_at": now_iso(),
|
"created_at": now_iso(),
|
||||||
"updated_at": now_iso(),
|
"updated_at": now_iso(),
|
||||||
"current_task_id": safe_task_id(current_task_id) if current_task_id else "",
|
"current_task_id": safe_task_id(current_task_id) if current_task_id else "",
|
||||||
"messages": [],
|
"messages": [],
|
||||||
"attachments": [],
|
"attachments": [],
|
||||||
}
|
}
|
||||||
write_json(path, record)
|
_write_json(path, record)
|
||||||
return record
|
return record
|
||||||
|
|
||||||
def read_conversation(self, conversation_id: str) -> dict[str, Any] | None:
|
def read_conversation(self, conversation_id: str) -> dict[str, Any] | None:
|
||||||
return read_json(self.conversation_path(conversation_id))
|
return _read_json(self.conversation_path(conversation_id))
|
||||||
|
|
||||||
def append_conversation_message(self, conversation_id: str, message: dict[str, Any], current_task_id: str | None = None) -> dict[str, Any]:
|
def append_conversation_message(self, conversation_id: str, message: dict[str, Any], current_task_id: str | None = None) -> dict[str, Any]:
|
||||||
record = self.ensure_conversation(conversation_id, current_task_id)
|
record = self.ensure_conversation(conversation_id, current_task_id)
|
||||||
known = {str(item.get("id")) for item in record["messages"]}
|
known = {str(item.get("id") or "") for item in record["messages"] if isinstance(item, dict)}
|
||||||
if str(message.get("id")) not in known:
|
if str(message.get("id") or "") not in known:
|
||||||
record["messages"].append(message)
|
record["messages"].append(message)
|
||||||
if current_task_id:
|
if current_task_id:
|
||||||
record["current_task_id"] = safe_task_id(current_task_id)
|
record["current_task_id"] = safe_task_id(current_task_id)
|
||||||
record["updated_at"] = now_iso()
|
record["updated_at"] = now_iso()
|
||||||
write_json(self.conversation_path(record["conversation_id"]), record)
|
_write_json(self.conversation_path(record["conversation_id"]), record)
|
||||||
return record
|
return record
|
||||||
|
|
||||||
|
def conversation_attachment_path(self, conversation_id: str, relative_path: str) -> Path:
|
||||||
|
root = self.conversation_dir(conversation_id).resolve()
|
||||||
|
candidate = (root / _safe_relative_path(relative_path)).resolve()
|
||||||
|
if root != candidate and root not in candidate.parents:
|
||||||
|
raise ValueError("Attachment path escapes conversation directory")
|
||||||
|
return candidate
|
||||||
|
|
||||||
def write_conversation_upload(self, conversation_id: str, filename: str, data: bytes) -> tuple[str, Path]:
|
def write_conversation_upload(self, conversation_id: str, filename: str, data: bytes) -> tuple[str, Path]:
|
||||||
conversation = safe_conversation_id(conversation_id)
|
conversation = safe_conversation_id(conversation_id)
|
||||||
safe_name = re.sub(r"[^a-zA-Z0-9._-]+", "_", Path(filename).name).strip("._") or "attachment"
|
safe_name = re.sub(r"[^a-zA-Z0-9._-]+", "_", Path(filename).name).strip("._") or "attachment"
|
||||||
relative = Path("uploads") / f"upload_{secrets.token_hex(6)}_{safe_name}"
|
relative = (Path("uploads") / f"upload_{secrets.token_hex(6)}_{safe_name}").as_posix()
|
||||||
target = self.conversation_attachment_path(conversation, relative.as_posix())
|
target = self.conversation_attachment_path(conversation, relative)
|
||||||
target.parent.mkdir(parents=True, exist_ok=True)
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
target.write_bytes(data)
|
target.write_bytes(data)
|
||||||
return relative.as_posix(), target
|
return relative, target
|
||||||
|
|
||||||
def add_conversation_attachment(self, conversation_id: str, attachment: dict[str, Any]) -> dict[str, Any]:
|
def add_conversation_attachment(self, conversation_id: str, attachment: dict[str, Any]) -> dict[str, Any]:
|
||||||
conversation = safe_conversation_id(conversation_id)
|
conversation = safe_conversation_id(conversation_id)
|
||||||
@@ -166,522 +139,5 @@ class WorkspaceStore:
|
|||||||
raise ValueError("Attachment already exists")
|
raise ValueError("Attachment already exists")
|
||||||
attachments.append(attachment)
|
attachments.append(attachment)
|
||||||
record["updated_at"] = now_iso()
|
record["updated_at"] = now_iso()
|
||||||
write_json(self.conversation_path(conversation), record)
|
_write_json(self.conversation_path(conversation), record)
|
||||||
return record
|
return record
|
||||||
|
|
||||||
def ensure_task(self, task_id: str | None, request: str) -> dict[str, Any]:
|
|
||||||
tid = safe_task_id(task_id) if task_id else new_id("cad")
|
|
||||||
path = self.task_path(tid)
|
|
||||||
current = read_json(path)
|
|
||||||
if current:
|
|
||||||
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": "2.0",
|
|
||||||
"task_id": tid,
|
|
||||||
"request": request,
|
|
||||||
"source_requirements_path": "source-requirements.md",
|
|
||||||
"created_at": now_iso(),
|
|
||||||
"updated_at": now_iso(),
|
|
||||||
"current_revision": "",
|
|
||||||
"active_revision": "",
|
|
||||||
"published_revision": "",
|
|
||||||
"lifecycle": "completed",
|
|
||||||
"run_id": "",
|
|
||||||
"requirements_path": "",
|
|
||||||
"completion_checklist_path": "",
|
|
||||||
"modeling_plan_path": "",
|
|
||||||
"modeling_plan_review_path": "",
|
|
||||||
"modeling_plan_version": 0,
|
|
||||||
"agent_state_path": "",
|
|
||||||
"active_candidate_id": "",
|
|
||||||
"active_branch_id": "main",
|
|
||||||
"run_failure_path": "",
|
|
||||||
"revisions": [],
|
|
||||||
}
|
|
||||||
write_json(path, record)
|
|
||||||
return record
|
|
||||||
|
|
||||||
def _migrate_task(self, task: dict[str, Any], path: Path) -> dict[str, Any]:
|
|
||||||
"""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 = {
|
|
||||||
"active_revision": current,
|
|
||||||
"published_revision": current,
|
|
||||||
"lifecycle": "completed",
|
|
||||||
"run_id": "",
|
|
||||||
"requirements_path": "",
|
|
||||||
"source_requirements_path": "",
|
|
||||||
"completion_checklist_path": "",
|
|
||||||
"modeling_plan_path": "",
|
|
||||||
"modeling_plan_review_path": "",
|
|
||||||
"modeling_plan_version": 0,
|
|
||||||
"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
|
|
||||||
if "visibility" not in revision:
|
|
||||||
revision["visibility"] = "final" if str(revision.get("revision_id") or "") == str(task["published_revision"] or "") else "checkpoint"
|
|
||||||
changed = True
|
|
||||||
if "branch_id" not in revision:
|
|
||||||
revision["branch_id"] = "main"
|
|
||||||
changed = True
|
|
||||||
if changed:
|
|
||||||
task["updated_at"] = now_iso()
|
|
||||||
write_json(path, task)
|
|
||||||
return task
|
|
||||||
|
|
||||||
def next_revision(self, task_id: str) -> tuple[str, Path]:
|
|
||||||
task = self.ensure_task(task_id, "")
|
|
||||||
revision_id = f"rev_{len(task['revisions']) + 1:03d}"
|
|
||||||
revision_dir = self.task_dir(task_id) / "revisions" / revision_id
|
|
||||||
revision_dir.mkdir(parents=True, exist_ok=False)
|
|
||||||
return revision_id, revision_dir
|
|
||||||
|
|
||||||
def update_task(self, task_id: str, revision: dict[str, Any]) -> dict[str, Any]:
|
|
||||||
task = self.ensure_task(task_id, "")
|
|
||||||
task["revisions"].append(revision)
|
|
||||||
if revision.get("status") == "success":
|
|
||||||
task["current_revision"] = revision["revision_id"]
|
|
||||||
task["active_revision"] = revision["revision_id"]
|
|
||||||
if revision.get("visibility") == "final":
|
|
||||||
task["published_revision"] = revision["revision_id"]
|
|
||||||
task["updated_at"] = now_iso()
|
|
||||||
write_json(self.task_path(task_id), task)
|
|
||||||
return task
|
|
||||||
|
|
||||||
def update_task_fields(self, task_id: str, fields: dict[str, Any]) -> dict[str, Any]:
|
|
||||||
"""Update task metadata without appending a synthetic revision."""
|
|
||||||
task = self.ensure_task(task_id, "")
|
|
||||||
for key, value in fields.items():
|
|
||||||
task[str(key)] = deepcopy(value)
|
|
||||||
task["updated_at"] = now_iso()
|
|
||||||
write_json(self.task_path(task_id), task)
|
|
||||||
return task
|
|
||||||
|
|
||||||
def read_task(self, task_id: str) -> dict[str, Any] | None:
|
|
||||||
task = read_json(self.task_path(task_id))
|
|
||||||
return self._migrate_task(task, self.task_path(task_id)) if isinstance(task, dict) else None
|
|
||||||
|
|
||||||
def start_generation(self, task_id: str, *, request: str, run_id: str | None = None) -> dict[str, Any]:
|
|
||||||
task = self.ensure_task(task_id, request)
|
|
||||||
if str(task.get("lifecycle") or "") == "running":
|
|
||||||
raise ValueError("CAD task is already running")
|
|
||||||
task.update({
|
|
||||||
"lifecycle": "running",
|
|
||||||
"run_id": run_id or new_id("run"),
|
|
||||||
"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)
|
|
||||||
return task
|
|
||||||
|
|
||||||
def finish_generation(self, task_id: str, *, lifecycle: str, failure: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
||||||
if lifecycle not in {"completed", "failed", "cancelled", "waiting_review", "failed_review_service"}:
|
|
||||||
raise ValueError("Generation lifecycle must be completed, failed, cancelled, waiting_review, or failed_review_service")
|
|
||||||
task = self.ensure_task(task_id, "")
|
|
||||||
failure_path = ""
|
|
||||||
if failure:
|
|
||||||
failure_path = "run-failures/" + f"failure_{secrets.token_hex(8)}.json"
|
|
||||||
write_json(self.task_dir(task_id) / failure_path, failure)
|
|
||||||
if lifecycle == "completed":
|
|
||||||
task["published_revision"] = str(task.get("active_revision") or task.get("current_revision") or "")
|
|
||||||
for revision in task.get("revisions") or ():
|
|
||||||
if isinstance(revision, dict) and revision.get("revision_id") == task["published_revision"]:
|
|
||||||
revision["visibility"] = "final"
|
|
||||||
task.update({
|
|
||||||
"lifecycle": lifecycle,
|
|
||||||
"active_candidate_id": "",
|
|
||||||
"run_failure_path": failure_path,
|
|
||||||
"updated_at": now_iso(),
|
|
||||||
})
|
|
||||||
write_json(self.task_path(task_id), task)
|
|
||||||
return task
|
|
||||||
|
|
||||||
def set_active_revision(self, task_id: str, revision_id: str, *, branch_id: str | None = None) -> dict[str, Any]:
|
|
||||||
task = self.ensure_task(task_id, "")
|
|
||||||
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
|
|
||||||
revision = next((item for item in task.get("revisions") or () if isinstance(item, dict) and item.get("revision_id") == revision_id), None)
|
|
||||||
if not isinstance(revision, dict) or revision.get("status") != "success":
|
|
||||||
raise ValueError("Active revision must be a successful revision")
|
|
||||||
task["active_revision"] = revision_id
|
|
||||||
task["current_revision"] = revision_id
|
|
||||||
if branch_id:
|
|
||||||
task["active_branch_id"] = branch_id
|
|
||||||
task["updated_at"] = now_iso()
|
|
||||||
write_json(self.task_path(task_id), task)
|
|
||||||
return task
|
|
||||||
|
|
||||||
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, "")
|
|
||||||
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 write_modeling_plan(self, task_id: str, markdown: str, *, version: int = 1) -> Path:
|
|
||||||
"""Persist one immutable, versioned modeling-plan document."""
|
|
||||||
text = str(markdown or "").strip()
|
|
||||||
if not text:
|
|
||||||
raise ValueError("modeling plan must not be empty")
|
|
||||||
task = self.ensure_task(task_id, "")
|
|
||||||
version = max(1, int(version))
|
|
||||||
relative = Path("plans") / f"modeling-plan-v{version}.md"
|
|
||||||
path = self.task_dir(task_id) / relative
|
|
||||||
if path.exists():
|
|
||||||
raise ValueError("modeling plan version already exists")
|
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
path.write_text(text + "\n", encoding="utf-8")
|
|
||||||
task["modeling_plan_path"] = relative.as_posix()
|
|
||||||
task["modeling_plan_version"] = version
|
|
||||||
task["updated_at"] = now_iso()
|
|
||||||
write_json(self.task_path(task_id), task)
|
|
||||||
return path
|
|
||||||
|
|
||||||
def read_modeling_plan(self, task_id: str) -> str:
|
|
||||||
task = self.read_task(task_id) or {}
|
|
||||||
relative = str(task.get("modeling_plan_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 write_modeling_plan_review(self, task_id: str, review: dict[str, Any], *, version: int) -> Path:
|
|
||||||
task = self.ensure_task(task_id, "")
|
|
||||||
version = max(1, int(version))
|
|
||||||
relative = Path("plans") / f"modeling-plan-v{version}.review.json"
|
|
||||||
path = self.task_dir(task_id) / relative
|
|
||||||
if path.exists():
|
|
||||||
raise ValueError("modeling plan review already exists")
|
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
write_json(path, review)
|
|
||||||
task["modeling_plan_review_path"] = relative.as_posix()
|
|
||||||
task["updated_at"] = now_iso()
|
|
||||||
write_json(self.task_path(task_id), task)
|
|
||||||
return path
|
|
||||||
|
|
||||||
def read_modeling_plan_review(self, task_id: str) -> dict[str, Any] | None:
|
|
||||||
task = self.read_task(task_id) or {}
|
|
||||||
relative = str(task.get("modeling_plan_review_path") or "")
|
|
||||||
value = read_json(self.artifact_path(task_id, relative), {}) if relative else None
|
|
||||||
return value if isinstance(value, dict) else None
|
|
||||||
|
|
||||||
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)
|
|
||||||
if not isinstance(revision, dict):
|
|
||||||
raise ValueError("Revision does not exist")
|
|
||||||
revision.update(values)
|
|
||||||
task["updated_at"] = now_iso()
|
|
||||||
write_json(self.task_path(task_id), task)
|
|
||||||
return task
|
|
||||||
|
|
||||||
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.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 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()))
|
|
||||||
while pending:
|
|
||||||
child = pending.pop()
|
|
||||||
if not child or child in superseded:
|
|
||||||
continue
|
|
||||||
superseded.add(child)
|
|
||||||
pending.extend(children.get(child, set()))
|
|
||||||
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
|
|
||||||
|
|
||||||
def rollback_anchor_for_nodes(
|
|
||||||
self,
|
|
||||||
task_id: str,
|
|
||||||
node_ids: list[str],
|
|
||||||
*,
|
|
||||||
fallback_revision_id: str = "",
|
|
||||||
) -> str:
|
|
||||||
"""Return the revision before every affected node's latest checkpoint.
|
|
||||||
|
|
||||||
Returning each affected revision's parent (rather than the revision
|
|
||||||
itself) ensures the faulty node is regenerated. A common ancestor
|
|
||||||
keeps unrelated upstream work intact while permitting a single rollback
|
|
||||||
over any number of affected nodes.
|
|
||||||
"""
|
|
||||||
task = self.read_task(task_id) or {}
|
|
||||||
revisions = [item for item in task.get("revisions") or () if isinstance(item, dict)]
|
|
||||||
by_id = {str(item.get("revision_id") or ""): item for item in revisions}
|
|
||||||
parents: list[str] = []
|
|
||||||
for node_id in dict.fromkeys(str(item) for item in node_ids if str(item)):
|
|
||||||
matching = [item for item in revisions if item.get("status") == "success" and str(item.get("node_id") or "") == node_id]
|
|
||||||
if matching:
|
|
||||||
parents.append(str(matching[-1].get("parent_revision_id") or ""))
|
|
||||||
if not parents:
|
|
||||||
return fallback_revision_id
|
|
||||||
|
|
||||||
def lineage(revision_id: str) -> list[str]:
|
|
||||||
chain = [revision_id]
|
|
||||||
seen = {revision_id}
|
|
||||||
current = revision_id
|
|
||||||
while current:
|
|
||||||
parent = str((by_id.get(current) or {}).get("parent_revision_id") or "")
|
|
||||||
if parent in seen:
|
|
||||||
break
|
|
||||||
chain.append(parent)
|
|
||||||
seen.add(parent)
|
|
||||||
current = parent
|
|
||||||
return chain
|
|
||||||
|
|
||||||
common = set(lineage(parents[0]))
|
|
||||||
for parent in parents[1:]:
|
|
||||||
common.intersection_update(lineage(parent))
|
|
||||||
if not common:
|
|
||||||
return fallback_revision_id
|
|
||||||
return next((revision for revision in lineage(parents[0]) if revision in common), fallback_revision_id)
|
|
||||||
|
|
||||||
def write_generation_failure(self, task_id: str, payload: dict[str, Any]) -> str:
|
|
||||||
"""Persist an attempt-level diagnostic without changing lifecycle."""
|
|
||||||
relative = Path("generation-failures") / f"failure_{secrets.token_hex(8)}.json"
|
|
||||||
write_json(self.task_dir(task_id) / relative, payload)
|
|
||||||
return relative.as_posix()
|
|
||||||
|
|
||||||
def running_tasks(self) -> list[dict[str, Any]]:
|
|
||||||
"""Enumerate durable tasks that need a process-local worker."""
|
|
||||||
tasks: list[dict[str, Any]] = []
|
|
||||||
for candidate in self.settings.task_root.glob("cad_*"):
|
|
||||||
if not candidate.is_dir() or not TASK_ID.fullmatch(candidate.name):
|
|
||||||
continue
|
|
||||||
task = self.read_task(candidate.name)
|
|
||||||
if isinstance(task, dict) and task.get("lifecycle") == "running":
|
|
||||||
tasks.append(task)
|
|
||||||
return tasks
|
|
||||||
|
|
||||||
def revision_dir(self, task_id: str, revision_id: str) -> Path:
|
|
||||||
return self.task_dir(task_id) / "revisions" / revision_id
|
|
||||||
|
|
||||||
def current_cdsl_path(self, task_id: str) -> Path | None:
|
|
||||||
task = self.read_task(task_id)
|
|
||||||
revision_id = str((task or {}).get("active_revision") or (task or {}).get("current_revision") or "")
|
|
||||||
if not revision_id:
|
|
||||||
return None
|
|
||||||
candidate = self.task_dir(task_id) / "revisions" / revision_id / "model.cdsl.json"
|
|
||||||
return candidate if candidate.is_file() else None
|
|
||||||
|
|
||||||
def revision_topology_path(self, task_id: str, revision_id: str) -> Path | None:
|
|
||||||
task = self.read_task(task_id)
|
|
||||||
revision = next(
|
|
||||||
(item for item in (task or {}).get("revisions") or [] if str(item.get("revision_id") or "") == revision_id),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
if not isinstance(revision, dict):
|
|
||||||
return None
|
|
||||||
relative = str(revision.get("topology_path") or "")
|
|
||||||
if not relative:
|
|
||||||
return None
|
|
||||||
candidate = self.artifact_path(task_id, relative)
|
|
||||||
return candidate if candidate.is_file() else None
|
|
||||||
|
|
||||||
def current_topology_path(self, task_id: str) -> Path | None:
|
|
||||||
task = self.read_task(task_id)
|
|
||||||
revision_id = str((task or {}).get("active_revision") or (task or {}).get("current_revision") or "")
|
|
||||||
if not revision_id:
|
|
||||||
return None
|
|
||||||
return self.revision_topology_path(task_id, revision_id)
|
|
||||||
|
|
||||||
def revision_cdsl_path(self, task_id: str, revision_id: str) -> Path | None:
|
|
||||||
task = self.read_task(task_id)
|
|
||||||
revision = next(
|
|
||||||
(item for item in (task or {}).get("revisions") or [] if str(item.get("revision_id") or "") == revision_id),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
if not isinstance(revision, dict):
|
|
||||||
return None
|
|
||||||
relative = str(revision.get("cdsl_path") or "")
|
|
||||||
if not relative:
|
|
||||||
return None
|
|
||||||
candidate = self.artifact_path(task_id, relative)
|
|
||||||
return candidate if candidate.is_file() else None
|
|
||||||
|
|
||||||
def artifact_path(self, task_id: str, relative_path: str) -> Path:
|
|
||||||
safe = safe_relative_path(relative_path)
|
|
||||||
root = self.task_dir(task_id).resolve()
|
|
||||||
target = (root / safe).resolve()
|
|
||||||
if root != target and root not in target.parents:
|
|
||||||
raise ValueError("Artifact path escapes task directory")
|
|
||||||
return target
|
|
||||||
|
|
||||||
def conversation_attachment_path(self, conversation_id: str, relative_path: str) -> Path:
|
|
||||||
safe = safe_relative_path(relative_path)
|
|
||||||
root = self.conversation_dir(conversation_id).resolve()
|
|
||||||
target = (root / safe).resolve()
|
|
||||||
if root != target and root not in target.parents:
|
|
||||||
raise ValueError("Attachment path escapes conversation directory")
|
|
||||||
return target
|
|
||||||
|
|||||||
@@ -1,591 +0,0 @@
|
|||||||
"""Independent, structured visual review of generated checkpoint renders."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import base64
|
|
||||||
import copy
|
|
||||||
import json
|
|
||||||
import re
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
|
|
||||||
from app.settings import ProviderConfig, ProviderModel, Settings
|
|
||||||
|
|
||||||
|
|
||||||
VISUAL_REVIEW_TOOL = {
|
|
||||||
"type": "function",
|
|
||||||
"function": {
|
|
||||||
"name": "review_rendered_checkpoint",
|
|
||||||
"description": "Review fixed CAD render views against frozen requirements. Never author or modify CDSL.",
|
|
||||||
"parameters": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"verdict": {"enum": ["pass", "warning", "repair"]},
|
|
||||||
"confidence": {"type": "number", "minimum": 0, "maximum": 1},
|
|
||||||
"affected_node_ids": {"type": "array", "items": {"type": "string"}, "maxItems": 12},
|
|
||||||
"requirement_ids": {"type": "array", "items": {"type": "string"}, "maxItems": 32},
|
|
||||||
"evidence": {"type": "array", "items": {"type": "string"}, "maxItems": 12},
|
|
||||||
},
|
|
||||||
"required": ["verdict", "confidence", "affected_node_ids", "requirement_ids", "evidence"],
|
|
||||||
"additionalProperties": False,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
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,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
MODELING_PLAN_REVIEW_TOOL = {
|
|
||||||
"type": "function",
|
|
||||||
"function": {
|
|
||||||
"name": "review_modeling_plan",
|
|
||||||
"description": "Independently review a CAD modeling plan for requirement coverage, coherent step grouping, dependencies, and observable evidence. Never generate or modify CDSL.",
|
|
||||||
"parameters": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"verdict": {"enum": ["pass", "revise"]},
|
|
||||||
"confidence": {"type": "number", "minimum": 0, "maximum": 1},
|
|
||||||
"issues": {"type": "array", "items": {"type": "object", "properties": {"type": {"type": "string"}, "step_id": {"type": "string"}, "message": {"type": "string"}}, "required": ["type", "message"], "additionalProperties": False}, "maxItems": 20},
|
|
||||||
"coverage": {"type": "array", "items": {"type": "object", "properties": {"requirement": {"type": "string"}, "step_id": {"type": "string"}, "status": {"enum": ["covered", "missing"]}, "evidence": {"type": "string"}}, "required": ["requirement", "step_id", "status", "evidence"], "additionalProperties": False}},
|
|
||||||
"step_checks": {"type": "array", "items": {"type": "object", "properties": {"step_id": {"type": "string"}, "status": {"enum": ["pass", "fail"]}, "notes": {"type": "string"}}, "required": ["step_id", "status", "notes"], "additionalProperties": False}},
|
|
||||||
"action_checks": {"type": "array", "items": {"type": "object", "properties": {"step_id": {"type": "string"}, "status": {"enum": ["pass", "fail"]}, "notes": {"type": "string"}, "required_action_count": {"type": "integer", "minimum": 1}}, "required": ["step_id", "status", "notes", "required_action_count"], "additionalProperties": False}},
|
|
||||||
},
|
|
||||||
"required": ["verdict", "confidence", "issues", "coverage", "step_checks", "action_checks"],
|
|
||||||
"additionalProperties": False,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class VisualReviewError(RuntimeError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class _CandidateReviewFormatError(VisualReviewError):
|
|
||||||
"""A locally detected invalid reviewer tool result, eligible for one retry."""
|
|
||||||
|
|
||||||
|
|
||||||
class _ModelingPlanReviewFormatError(VisualReviewError):
|
|
||||||
"""A locally detected invalid modeling-plan verdict, eligible for one retry."""
|
|
||||||
|
|
||||||
|
|
||||||
def _checklist_key(value: Any) -> str:
|
|
||||||
return " ".join(str(value or "").strip().split()).casefold()
|
|
||||||
|
|
||||||
|
|
||||||
def _looks_like_operation_id(value: str) -> bool:
|
|
||||||
token = str(value or "").strip().casefold()
|
|
||||||
if not token or "_" not in token:
|
|
||||||
return token in {"fillet", "chamfer"}
|
|
||||||
prefixes = ("extrude_", "revolve_", "hole_", "pattern_", "sphere_", "reference_", "cylinder_", "sweep_")
|
|
||||||
suffixes = ("_add", "_cut", "_blind", "_wizard", "_linear", "_circular", "_mirror")
|
|
||||||
return token.startswith(prefixes) or token.endswith(suffixes)
|
|
||||||
|
|
||||||
|
|
||||||
def _unsupported_operation_mentions(result: dict[str, Any], runtime_operations: list[dict[str, Any]]) -> list[str]:
|
|
||||||
"""Find capability-shaped names in reviewer prose that Runtime cannot execute."""
|
|
||||||
supported = {
|
|
||||||
str(item.get("atomic_id") or "").strip().casefold()
|
|
||||||
for item in runtime_operations
|
|
||||||
if isinstance(item, dict) and str(item.get("atomic_id") or "").strip()
|
|
||||||
}
|
|
||||||
mentions: set[str] = set()
|
|
||||||
for section in (result.get("issues"), result.get("coverage"), result.get("step_checks"), result.get("action_checks")):
|
|
||||||
if not isinstance(section, list):
|
|
||||||
continue
|
|
||||||
for row in section:
|
|
||||||
if not isinstance(row, dict):
|
|
||||||
continue
|
|
||||||
for value in row.values():
|
|
||||||
if not isinstance(value, str):
|
|
||||||
continue
|
|
||||||
for token in re.findall(r"(?<![A-Za-z0-9_])([A-Za-z][A-Za-z0-9_]*)(?![A-Za-z0-9_])", value):
|
|
||||||
normalized = token.casefold()
|
|
||||||
if normalized in supported or not _looks_like_operation_id(normalized):
|
|
||||||
continue
|
|
||||||
# Runtime capabilities are exact identifiers. A prefix
|
|
||||||
# or human shorthand (for example extrude_cut for
|
|
||||||
# extrude_cut_blind) is still invalid and must be revised.
|
|
||||||
mentions.add(token)
|
|
||||||
return sorted(mentions, key=str.casefold)
|
|
||||||
|
|
||||||
|
|
||||||
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 modeling_plan_review_tool() -> dict[str, Any]:
|
|
||||||
return json.loads(json.dumps(MODELING_PLAN_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"
|
|
||||||
return {"type": "image_url", "image_url": {"url": f"data:{media};base64,{encoded}"}}
|
|
||||||
|
|
||||||
|
|
||||||
def _selected_review_views(manifest: dict[str, Any], *, final_checkpoint: bool) -> list[dict[str, Any]]:
|
|
||||||
"""Keep each reviewer call bounded while canonical evidence stays archived.
|
|
||||||
|
|
||||||
A contact sheet establishes global context. Up to two planned detail views
|
|
||||||
provide node-specific evidence. The final checkpoint adds full canonical
|
|
||||||
views because it is the only point where those extra image tokens pay off.
|
|
||||||
"""
|
|
||||||
views = [item for item in manifest.get("views") or () if isinstance(item, dict)]
|
|
||||||
by_id = {str(item.get("id") or ""): item for item in views}
|
|
||||||
selected: list[dict[str, Any]] = []
|
|
||||||
contact = Path(str(manifest.get("contact_sheet_path") or ""))
|
|
||||||
if contact.is_file():
|
|
||||||
selected.append({"id": "contact-sheet", "path": str(contact), "camera": {"projection": "mixed"}})
|
|
||||||
for view_id in ("detail-1", "detail-2"):
|
|
||||||
item = by_id.get(view_id)
|
|
||||||
if item is not None:
|
|
||||||
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)
|
|
||||||
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]
|
|
||||||
|
|
||||||
|
|
||||||
async def review_checkpoint(
|
|
||||||
settings: Settings,
|
|
||||||
*,
|
|
||||||
manifest: dict[str, Any],
|
|
||||||
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,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
provider, model = settings.resolve_review_model()
|
|
||||||
views = _selected_review_views(manifest, final_checkpoint=final_checkpoint)
|
|
||||||
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("Review render manifest references missing image files")
|
|
||||||
content: list[dict[str, Any]] = [{
|
|
||||||
"type": "text",
|
|
||||||
"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. 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)
|
|
||||||
# Reference images are only supplementary evidence. Keep this bounded so
|
|
||||||
# an attachment-heavy request does not dominate every checkpoint review.
|
|
||||||
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 = visual_review_tool()
|
|
||||||
payload = {
|
|
||||||
"model": model.id,
|
|
||||||
"messages": [
|
|
||||||
{"role": "system", "content": "You are an independent CAD visual reviewer. You may only call review_rendered_checkpoint."},
|
|
||||||
{"role": "user", "content": content},
|
|
||||||
],
|
|
||||||
"tools": [tool],
|
|
||||||
"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:
|
|
||||||
call = response.json()["choices"][0]["message"]["tool_calls"][0]
|
|
||||||
if call["function"]["name"] != "review_rendered_checkpoint":
|
|
||||||
raise KeyError("wrong tool")
|
|
||||||
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
|
|
||||||
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"))
|
|
||||||
except (TypeError, ValueError) as error:
|
|
||||||
raise VisualReviewError("Visual reviewer returned an invalid confidence") from error
|
|
||||||
if not 0 <= confidence <= 1:
|
|
||||||
raise VisualReviewError("Visual reviewer confidence is outside [0, 1]")
|
|
||||||
for key in ("affected_node_ids", "requirement_ids", "evidence"):
|
|
||||||
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,
|
|
||||||
plan_step: dict[str, Any] | None = None,
|
|
||||||
plan_feature_ids: list[str] | None = None,
|
|
||||||
plan_action: dict[str, Any] | None = None,
|
|
||||||
) -> 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,
|
|
||||||
"plan_step": plan_step or None,
|
|
||||||
"plan_feature_ids": plan_feature_ids or [],
|
|
||||||
"plan_action": plan_action or None,
|
|
||||||
"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. "
|
|
||||||
"When plan_step is supplied, reject operations outside that step and confirm its planned feature goal. "
|
|
||||||
"A step may contain multiple related actions; when plan_action is supplied, assess only that action, confirm the candidate changes its target, and reject unrelated or multiple independent profiles."
|
|
||||||
),
|
|
||||||
"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,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async def review_modeling_plan(
|
|
||||||
settings: Settings,
|
|
||||||
*,
|
|
||||||
source_requirements: str,
|
|
||||||
requirements: str,
|
|
||||||
checklist: list[str],
|
|
||||||
plan: dict[str, Any],
|
|
||||||
runtime_operations: list[dict[str, Any]],
|
|
||||||
node_id: str = "modeling-plan",
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""Ask an independent model to simulate and review a modeling plan."""
|
|
||||||
provider, model = settings.resolve_review_model()
|
|
||||||
payload_context = {
|
|
||||||
"node_id": node_id,
|
|
||||||
"source_requirements": source_requirements,
|
|
||||||
"frozen_requirements": requirements,
|
|
||||||
"completion_checklist": checklist,
|
|
||||||
"modeling_plan": plan,
|
|
||||||
"runtime_operations": runtime_operations,
|
|
||||||
"instruction": (
|
|
||||||
"Review the plan only; do not generate CDSL. Treat modeling_plan.plan_text as semantic "
|
|
||||||
"guidance, not a schema: missing structures/features arrays or machine IDs are not by "
|
|
||||||
"themselves failures. Infer intended structures, ordering, relationships, and evidence "
|
|
||||||
"from the prose. Check every checklist item, dependency order, step cohesion, topology "
|
|
||||||
"preconditions, Runtime feasibility, action granularity, and observable evidence. A semantic "
|
|
||||||
"step may contain related or repeated targets, but each independently located profile "
|
|
||||||
"or Runtime feature must be enumerated as a separate action within that same step. Each action "
|
|
||||||
"must be executable as one Runtime feature. A multi-position hole or supported pattern may remain "
|
|
||||||
"one action when all positions share the same host and operation contract. Treat any supplied "
|
|
||||||
"action records as execution units, and set required_action_count to the minimum number the step's "
|
|
||||||
"semantics require. Return fail when the plan contains fewer actions than that number. Return pass when the plan "
|
|
||||||
"is actionable enough for an LLM to generate CDSL in coherent batches. Do not require "
|
|
||||||
"colors or materials when runtime_operations does not provide them; accept a geometrically "
|
|
||||||
"equivalent ring, groove, or separated feature and mention the limitation only as guidance. "
|
|
||||||
"Only return revise for a real requirement omission, contradictory geometry, unsafe ordering, "
|
|
||||||
"an operation name that is not present in runtime_operations, or an operation that cannot plausibly be implemented. "
|
|
||||||
"Do not downgrade an unsupported operation to vague guidance. If a machine operation is mentioned, it must be an exact ID from runtime_operations; otherwise return revise and describe the semantic intent without inventing an operation name. "
|
|
||||||
"Return exactly one coverage row per checklist item."
|
|
||||||
),
|
|
||||||
}
|
|
||||||
payload = {
|
|
||||||
"model": model.id,
|
|
||||||
"messages": [
|
|
||||||
{"role": "system", "content": "You are an independent CAD modeling-plan reviewer. You may only call review_modeling_plan."},
|
|
||||||
{"role": "user", "content": json.dumps(payload_context, ensure_ascii=False)},
|
|
||||||
],
|
|
||||||
"tools": [modeling_plan_review_tool()],
|
|
||||||
"tool_choice": {"type": "function", "function": {"name": "review_modeling_plan"}},
|
|
||||||
"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"Modeling plan 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_modeling_plan":
|
|
||||||
raise KeyError("wrong tool")
|
|
||||||
result = json.loads(call["function"]["arguments"])
|
|
||||||
except (KeyError, IndexError, TypeError, json.JSONDecodeError) as error:
|
|
||||||
raise _ModelingPlanReviewFormatError("Modeling plan reviewer did not return a valid review tool call") from error
|
|
||||||
allowed = {"verdict", "confidence", "issues", "coverage", "step_checks", "action_checks"}
|
|
||||||
if not isinstance(result, dict) or set(result) != allowed or result.get("verdict") not in {"pass", "revise"}:
|
|
||||||
raise _ModelingPlanReviewFormatError("Modeling plan reviewer returned an invalid verdict")
|
|
||||||
try:
|
|
||||||
confidence = float(result.get("confidence"))
|
|
||||||
except (TypeError, ValueError) as error:
|
|
||||||
raise _ModelingPlanReviewFormatError("Modeling plan reviewer returned an invalid confidence") from error
|
|
||||||
if not 0 <= confidence <= 1:
|
|
||||||
raise _ModelingPlanReviewFormatError("Modeling plan reviewer confidence is outside [0, 1]")
|
|
||||||
if not isinstance(result.get("issues"), list) or not all(isinstance(item, dict) for item in result["issues"]):
|
|
||||||
raise _ModelingPlanReviewFormatError("Modeling plan reviewer returned invalid issues")
|
|
||||||
expected = {_checklist_key(item): item for item in checklist}
|
|
||||||
coverage = result.get("coverage")
|
|
||||||
if not isinstance(coverage, list) or len(coverage) != len(checklist):
|
|
||||||
raise _ModelingPlanReviewFormatError("Modeling plan reviewer must return coverage for every checklist item")
|
|
||||||
seen: set[str] = set()
|
|
||||||
for item in coverage:
|
|
||||||
if not isinstance(item, dict) or set(item) != {"requirement", "step_id", "status", "evidence"}:
|
|
||||||
raise _ModelingPlanReviewFormatError("Modeling plan reviewer returned an invalid coverage row")
|
|
||||||
key = _checklist_key(item.get("requirement"))
|
|
||||||
if key not in expected or key in seen or item.get("status") not in {"covered", "missing"} or not str(item.get("evidence") or "").strip():
|
|
||||||
raise _ModelingPlanReviewFormatError("Modeling plan reviewer coverage does not match the frozen checklist")
|
|
||||||
item["requirement"] = expected[key]
|
|
||||||
seen.add(key)
|
|
||||||
step_records = {
|
|
||||||
str(item.get("step_id") or ""): item
|
|
||||||
for item in plan.get("steps") or () if isinstance(item, dict)
|
|
||||||
}
|
|
||||||
steps = set(step_records)
|
|
||||||
checks = result.get("step_checks")
|
|
||||||
if not isinstance(checks, list) or len(checks) != len(steps):
|
|
||||||
raise _ModelingPlanReviewFormatError("Modeling plan reviewer must return one step check per plan step")
|
|
||||||
checked: set[str] = set()
|
|
||||||
for item in checks:
|
|
||||||
if not isinstance(item, dict) or set(item) != {"step_id", "status", "notes"}:
|
|
||||||
raise _ModelingPlanReviewFormatError("Modeling plan reviewer returned an invalid step check")
|
|
||||||
step_id = str(item.get("step_id") or "")
|
|
||||||
if step_id not in steps or step_id in checked or item.get("status") not in {"pass", "fail"}:
|
|
||||||
raise _ModelingPlanReviewFormatError("Modeling plan reviewer step checks do not match the plan")
|
|
||||||
checked.add(step_id)
|
|
||||||
action_checks = result.get("action_checks")
|
|
||||||
if not isinstance(action_checks, list) or len(action_checks) != len(steps):
|
|
||||||
raise _ModelingPlanReviewFormatError("Modeling plan reviewer must return one action check per plan step")
|
|
||||||
action_checked: set[str] = set()
|
|
||||||
for item in action_checks:
|
|
||||||
if not isinstance(item, dict) or set(item) != {"step_id", "status", "notes", "required_action_count"}:
|
|
||||||
raise _ModelingPlanReviewFormatError("Modeling plan reviewer returned an invalid action check")
|
|
||||||
step_id = str(item.get("step_id") or "")
|
|
||||||
required_count = item.get("required_action_count")
|
|
||||||
if (
|
|
||||||
step_id not in steps
|
|
||||||
or step_id in action_checked
|
|
||||||
or item.get("status") not in {"pass", "fail"}
|
|
||||||
or not isinstance(required_count, int)
|
|
||||||
or isinstance(required_count, bool)
|
|
||||||
or required_count < 1
|
|
||||||
):
|
|
||||||
raise _ModelingPlanReviewFormatError("Modeling plan reviewer action checks do not match the plan")
|
|
||||||
planned_actions = [
|
|
||||||
action for action in (step_records[step_id].get("actions") or ())
|
|
||||||
if isinstance(action, dict)
|
|
||||||
]
|
|
||||||
if required_count > len(planned_actions) and item.get("status") != "fail":
|
|
||||||
raise _ModelingPlanReviewFormatError(
|
|
||||||
"Modeling plan reviewer must fail an action check when required_action_count exceeds the plan's action count"
|
|
||||||
)
|
|
||||||
action_checked.add(step_id)
|
|
||||||
if result["verdict"] == "pass" and any(item.get("status") != "covered" for item in coverage):
|
|
||||||
raise _ModelingPlanReviewFormatError("Modeling plan reviewer may pass only when every checklist item is covered")
|
|
||||||
if result["verdict"] == "pass" and any(item.get("status") != "pass" for item in checks):
|
|
||||||
raise _ModelingPlanReviewFormatError("Modeling plan reviewer may pass only when every step check passes")
|
|
||||||
if result["verdict"] == "pass" and any(item.get("status") != "pass" for item in action_checks):
|
|
||||||
raise _ModelingPlanReviewFormatError("Modeling plan reviewer may pass only when every action check passes")
|
|
||||||
unsupported = _unsupported_operation_mentions(result, runtime_operations)
|
|
||||||
if unsupported:
|
|
||||||
# These strings came from the reviewer's explanatory prose, not
|
|
||||||
# from the submitted plan. The plan parser enforces exact IDs for
|
|
||||||
# explicit operation declarations; do not reject an otherwise
|
|
||||||
# valid semantic plan because the reviewer hallucinated a
|
|
||||||
# shorthand while describing an alternative. Keep this warning in
|
|
||||||
# the raw audit record and scrub it before author exposure.
|
|
||||||
result["unsupported_operations"] = unsupported
|
|
||||||
result["review_warnings"] = [
|
|
||||||
"Reviewer mentioned operation name(s) absent from runtime_operations; those names were ignored as guidance."
|
|
||||||
]
|
|
||||||
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 _ModelingPlanReviewFormatError as error:
|
|
||||||
retry_payload = copy.deepcopy(payload)
|
|
||||||
retry_payload["messages"].append({"role": "user", "content": f"Your previous review was rejected locally: {error}. Return only a complete review_modeling_plan tool call with one coverage row per checklist item and one step_checks and action_checks row per plan step."})
|
|
||||||
result = validate_response(await request_review(client, retry_payload))
|
|
||||||
return {"schema_version": "cad.modeling-plan-review.v1", "node_id": node_id, "model": model.id, **result}
|
|
||||||
@@ -29,6 +29,7 @@ class ProviderConfig:
|
|||||||
# Chat Completions uses the legacy flat ``reasoning_effort`` parameter.
|
# Chat Completions uses the legacy flat ``reasoning_effort`` parameter.
|
||||||
# Keep it provider-scoped because compatibility varies by endpoint/model.
|
# Keep it provider-scoped because compatibility varies by endpoint/model.
|
||||||
reasoning_effort: str = ""
|
reasoning_effort: str = ""
|
||||||
|
api_style: str = "chat_completions"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def configured(self) -> bool:
|
def configured(self) -> bool:
|
||||||
@@ -43,6 +44,14 @@ class ProviderConfig:
|
|||||||
return {"reasoning_effort": self.reasoning_effort}
|
return {"reasoning_effort": self.reasoning_effort}
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def request_options(self) -> dict[str, object]:
|
||||||
|
if not self.reasoning_effort:
|
||||||
|
return {}
|
||||||
|
if self.api_style == "responses":
|
||||||
|
return {"reasoning": {"effort": self.reasoning_effort}}
|
||||||
|
return {"reasoning_effort": self.reasoning_effort}
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class Settings:
|
class Settings:
|
||||||
@@ -59,14 +68,10 @@ class Settings:
|
|||||||
review_provider_id: str = ""
|
review_provider_id: str = ""
|
||||||
review_model_id: str = ""
|
review_model_id: str = ""
|
||||||
agent_tool_calls_per_cycle: int = 12
|
agent_tool_calls_per_cycle: int = 12
|
||||||
agent_candidate_attempts_per_head: int = 3
|
|
||||||
agent_consecutive_no_progress_limit: int = 6
|
agent_consecutive_no_progress_limit: int = 6
|
||||||
agent_format_error_repeat_limit: int = 3
|
agent_format_error_repeat_limit: int = 3
|
||||||
agent_max_features_per_fragment: int = 6
|
|
||||||
agent_context_char_limit: int = 14000
|
agent_context_char_limit: int = 14000
|
||||||
agent_render_cache: bool = True
|
agent_render_cache: bool = True
|
||||||
modeling_plan_enabled: bool = True
|
|
||||||
modeling_plan_max_revisions: int = 2
|
|
||||||
autonomous_generation: bool = True
|
autonomous_generation: bool = True
|
||||||
resume_running_tasks_on_startup: bool = True
|
resume_running_tasks_on_startup: bool = True
|
||||||
|
|
||||||
@@ -130,6 +135,13 @@ def _reasoning_effort(value: str) -> str:
|
|||||||
return effort
|
return effort
|
||||||
|
|
||||||
|
|
||||||
|
def _api_style(value: str) -> str:
|
||||||
|
style = value.strip().lower() or "chat_completions"
|
||||||
|
if style not in {"chat_completions", "responses"}:
|
||||||
|
raise ValueError("CDSL_*_API_STYLE must be 'chat_completions' or 'responses'")
|
||||||
|
return style
|
||||||
|
|
||||||
|
|
||||||
def _env_flag(name: str, default: bool) -> bool:
|
def _env_flag(name: str, default: bool) -> bool:
|
||||||
value = os.getenv(name)
|
value = os.getenv(name)
|
||||||
if value is None or not value.strip():
|
if value is None or not value.strip():
|
||||||
@@ -167,6 +179,7 @@ def _provider(prefix: str, provider_id: str, label: str, default_base_url: str,
|
|||||||
api_key=api_key,
|
api_key=api_key,
|
||||||
models=_models(model_list, vision_models),
|
models=_models(model_list, vision_models),
|
||||||
reasoning_effort=_reasoning_effort(os.getenv(f"CDSL_{prefix}_REASONING_EFFORT", "")),
|
reasoning_effort=_reasoning_effort(os.getenv(f"CDSL_{prefix}_REASONING_EFFORT", "")),
|
||||||
|
api_style=_api_style(os.getenv(f"CDSL_{prefix}_API_STYLE", "responses" if provider_id == "openai" else "chat_completions")),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -195,14 +208,10 @@ def get_settings() -> Settings:
|
|||||||
review_provider_id=os.getenv("CDSL_REVIEW_PROVIDER", "").strip().lower(),
|
review_provider_id=os.getenv("CDSL_REVIEW_PROVIDER", "").strip().lower(),
|
||||||
review_model_id=os.getenv("CDSL_REVIEW_MODEL", "").strip(),
|
review_model_id=os.getenv("CDSL_REVIEW_MODEL", "").strip(),
|
||||||
agent_tool_calls_per_cycle=max(1, int(os.getenv("CDSL_AGENT_TOOL_CALLS_PER_CYCLE", "12"))),
|
agent_tool_calls_per_cycle=max(1, int(os.getenv("CDSL_AGENT_TOOL_CALLS_PER_CYCLE", "12"))),
|
||||||
agent_candidate_attempts_per_head=max(1, int(os.getenv("CDSL_AGENT_CANDIDATE_ATTEMPTS_PER_HEAD", "3"))),
|
|
||||||
agent_consecutive_no_progress_limit=max(1, int(os.getenv("CDSL_AGENT_CONSECUTIVE_NO_PROGRESS_LIMIT", "6"))),
|
agent_consecutive_no_progress_limit=max(1, int(os.getenv("CDSL_AGENT_CONSECUTIVE_NO_PROGRESS_LIMIT", "6"))),
|
||||||
agent_format_error_repeat_limit=max(1, int(os.getenv("CDSL_AGENT_FORMAT_ERROR_REPEAT_LIMIT", "3"))),
|
agent_format_error_repeat_limit=max(1, int(os.getenv("CDSL_AGENT_FORMAT_ERROR_REPEAT_LIMIT", "3"))),
|
||||||
agent_max_features_per_fragment=max(1, min(6, int(os.getenv("CDSL_AGENT_MAX_FEATURES_PER_FRAGMENT", "6")))),
|
|
||||||
agent_context_char_limit=max(4000, int(os.getenv("CDSL_AGENT_CONTEXT_CHAR_LIMIT", "14000"))),
|
agent_context_char_limit=max(4000, int(os.getenv("CDSL_AGENT_CONTEXT_CHAR_LIMIT", "14000"))),
|
||||||
agent_render_cache=_env_flag("CDSL_AGENT_RENDER_CACHE", True),
|
agent_render_cache=_env_flag("CDSL_AGENT_RENDER_CACHE", True),
|
||||||
modeling_plan_enabled=_env_flag("CDSL_MODELING_PLAN_ENABLED", True),
|
|
||||||
modeling_plan_max_revisions=max(1, int(os.getenv("CDSL_MODELING_PLAN_MAX_REVISIONS", "2"))),
|
|
||||||
autonomous_generation=True,
|
autonomous_generation=True,
|
||||||
# Production instances recover durable runs by default. Test workers
|
# Production instances recover durable runs by default. Test workers
|
||||||
# can disable this before startup to guarantee they touch only tasks
|
# can disable this before startup to guarantee they touch only tasks
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from .rebuild import compare_with_gold, compile_cdsl_to_pack, run_cdsl_only, run
|
|||||||
from .semantic_validation import validate_semantic_cdsl
|
from .semantic_validation import validate_semantic_cdsl
|
||||||
from .sketch_solver import CORE_SHAPE_GENERATORS, resolve_all_sketches, resolve_required_sketches
|
from .sketch_solver import CORE_SHAPE_GENERATORS, resolve_all_sketches, resolve_required_sketches
|
||||||
from .runtime import ALL_ATOMIC_IDS, EXECUTORS, RuntimeExecutionError, analyze_cdsl, rebuild_cdsl
|
from .runtime import ALL_ATOMIC_IDS, EXECUTORS, RuntimeExecutionError, analyze_cdsl, rebuild_cdsl
|
||||||
|
from .operation_contracts import materialized_feature_contracts
|
||||||
|
|
||||||
# The package-level runtime contract is the session executor registry. The
|
# The package-level runtime contract is the session executor registry. The
|
||||||
# older llm_engine dispatcher remains available only for legacy engine packs.
|
# older llm_engine dispatcher remains available only for legacy engine packs.
|
||||||
@@ -37,6 +38,7 @@ __all__ = [
|
|||||||
"rebuild_cdsl",
|
"rebuild_cdsl",
|
||||||
"analyze_cdsl",
|
"analyze_cdsl",
|
||||||
"RuntimeExecutionError",
|
"RuntimeExecutionError",
|
||||||
|
"materialized_feature_contracts",
|
||||||
]
|
]
|
||||||
|
|
||||||
__version__ = "1.0.0"
|
__version__ = "1.0.0"
|
||||||
|
|||||||
@@ -343,6 +343,10 @@ def batch_analyze(
|
|||||||
if not files:
|
if not files:
|
||||||
raise ValueError(f"No *.cdsl.json files found in {cdsl_dir}")
|
raise ValueError(f"No *.cdsl.json files found in {cdsl_dir}")
|
||||||
if part_ids is not None:
|
if part_ids is not None:
|
||||||
|
# ``part_ids`` are exact CDSL filename stems. This is intentionally
|
||||||
|
# independent from document ``part_id``: imported provenance may use
|
||||||
|
# different case or normalisation than its collision-free artifact
|
||||||
|
# filename. Phase pools return these same stems.
|
||||||
requested = {str(part_id).strip() for part_id in part_ids if str(part_id).strip()}
|
requested = {str(part_id).strip() for part_id in part_ids if str(part_id).strip()}
|
||||||
available = {path.name.removesuffix(".cdsl.json") for path in files}
|
available = {path.name.removesuffix(".cdsl.json") for path in files}
|
||||||
unknown = sorted(requested - available)
|
unknown = sorted(requested - available)
|
||||||
|
|||||||
@@ -451,6 +451,11 @@ class Build123dGeometryAdapter:
|
|||||||
|
|
||||||
# 3. 导出面记录:含包围盒、中心、法向、面积、曲面类型与邻接签名;
|
# 3. 导出面记录:含包围盒、中心、法向、面积、曲面类型与邻接签名;
|
||||||
# 平面面额外写入规范化法向与平面偏移,便于后续按平面匹配。
|
# 平面面额外写入规范化法向与平面偏移,便于后续按平面匹配。
|
||||||
|
# 圆柱面还保存轴、半径和共享边关联的平面面。这使 verifier 能从
|
||||||
|
# 实际 B-rep 证明孔是否连接两个方向相反的外部平面,而不是根据
|
||||||
|
# author 传入的 blind-depth 文字猜测“贯穿”。
|
||||||
|
face_edge_indexes: list[set[int]] = []
|
||||||
|
face_geometries: list[dict[str, Any]] = []
|
||||||
for index, face in enumerate(faces):
|
for index, face in enumerate(faces):
|
||||||
bbox = face.bounding_box()
|
bbox = face.bounding_box()
|
||||||
center = face.center()
|
center = face.center()
|
||||||
@@ -472,10 +477,162 @@ class Build123dGeometryAdapter:
|
|||||||
)
|
)
|
||||||
geometry["plane_normal"] = list(plane_normal)
|
geometry["plane_normal"] = list(plane_normal)
|
||||||
geometry["plane_offset_mm"] = plane_offset
|
geometry["plane_offset_mm"] = plane_offset
|
||||||
|
boundary_loops: list[list[list[float]]] = []
|
||||||
|
for wire in face.wires():
|
||||||
|
samples: list[list[float]] = []
|
||||||
|
for edge in wire.edges():
|
||||||
|
curve_type = str(edge.geom_type).split(".")[-1].lower()
|
||||||
|
fractions = [step / 16 for step in range(16)] if curve_type in {"circle", "ellipse"} else [0.0]
|
||||||
|
for fraction in fractions:
|
||||||
|
point = edge.position_at(fraction)
|
||||||
|
value = [float(point.X), float(point.Y), float(point.Z)]
|
||||||
|
if not samples or sum((value[axis] - samples[-1][axis]) ** 2 for axis in range(3)) > 1e-12:
|
||||||
|
samples.append(value)
|
||||||
|
if len(samples) >= 3:
|
||||||
|
boundary_loops.append(samples)
|
||||||
|
if boundary_loops:
|
||||||
|
geometry["boundary_loops_mm"] = boundary_loops
|
||||||
|
elif geometry["surface_type"] in {"cylinder", "cone"}:
|
||||||
|
axis = face.axis_of_rotation
|
||||||
|
if axis is None:
|
||||||
|
raise ValueError("rotational face is missing an axis")
|
||||||
|
direction = axis.direction
|
||||||
|
origin = axis.position
|
||||||
|
geometry["axis_origin_mm"] = [origin.X, origin.Y, origin.Z]
|
||||||
|
geometry["axis_direction"] = [direction.X, direction.Y, direction.Z]
|
||||||
|
raw_cylinder_radius = face.radius if geometry["surface_type"] == "cylinder" else None
|
||||||
|
if geometry["surface_type"] == "cone":
|
||||||
|
boundary_radii: list[float] = []
|
||||||
|
for edge in face.edges():
|
||||||
|
if str(edge.geom_type).split(".")[-1].lower() != "circle":
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
boundary_radii.append(float(edge.radius))
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
geometry["boundary_radii_mm"] = sorted(boundary_radii)
|
||||||
|
geometry["semi_angle_deg"] = float(face.semi_angle) if face.semi_angle is not None else None
|
||||||
|
# ``through`` alone describes a cylinder spanning two opposed
|
||||||
|
# planar faces. That applies to both a through bore and the
|
||||||
|
# outside wall of a cylindrical extrusion. Classify the B-rep
|
||||||
|
# face by its oriented normal so downstream acceptance claims
|
||||||
|
# can prove holes without mistaking an exterior wall for one.
|
||||||
|
unit_axis = (direction.X, direction.Y, direction.Z)
|
||||||
|
radial = (center.X - origin.X, center.Y - origin.Y, center.Z - origin.Z)
|
||||||
|
axial_projection = sum(radial[component] * unit_axis[component] for component in range(3))
|
||||||
|
radial = tuple(radial[component] - axial_projection * unit_axis[component] for component in range(3))
|
||||||
|
radial_length = sum(component * component for component in radial) ** 0.5
|
||||||
|
if geometry["surface_type"] == "cylinder":
|
||||||
|
# OCC can report a cylinder surface with ``radius=None``
|
||||||
|
# after a non-planar-side Boolean cut. The face centre is
|
||||||
|
# still on that cylinder, so its perpendicular distance to
|
||||||
|
# the rotation axis is an equivalent measured radius. Do
|
||||||
|
# not fail an otherwise valid build merely because that
|
||||||
|
# optional OCC convenience property is absent.
|
||||||
|
if isinstance(raw_cylinder_radius, (int, float)) and math.isfinite(float(raw_cylinder_radius)):
|
||||||
|
geometry["radius_mm"] = float(raw_cylinder_radius)
|
||||||
|
elif radial_length > 1e-9:
|
||||||
|
geometry["radius_mm"] = radial_length
|
||||||
|
if radial_length > 1e-9:
|
||||||
|
normal_components = (normal.X, normal.Y, normal.Z)
|
||||||
|
alignment = sum(float(normal_components[component]) * radial[component] for component in range(3)) / radial_length
|
||||||
|
geometry["radial_normal_alignment"] = alignment
|
||||||
|
geometry["cylinder_role"] = "outer" if alignment > 0.5 else "inner" if alignment < -0.5 else "unknown"
|
||||||
|
else:
|
||||||
|
geometry["cylinder_role"] = "unknown"
|
||||||
|
face_edge_indexes.append(set(boundary_edge_indexes))
|
||||||
|
face_geometries.append(geometry)
|
||||||
records.append(TopologyRecord(
|
records.append(TopologyRecord(
|
||||||
record_id=f"{body_id}:face:{index}", kind="face", feature_id=feature_id, body_id=body_id, value=face,
|
record_id=f"{body_id}:face:{index}", kind="face", feature_id=feature_id, body_id=body_id, value=face,
|
||||||
geometry=geometry,
|
geometry=geometry,
|
||||||
))
|
))
|
||||||
|
plane_indexes = [index for index, geometry in enumerate(face_geometries) if geometry["surface_type"] == "plane"]
|
||||||
|
|
||||||
|
def directly_linked_planes(face_index: int) -> list[int]:
|
||||||
|
return [
|
||||||
|
plane_index
|
||||||
|
for plane_index in plane_indexes
|
||||||
|
if face_edge_indexes[face_index].intersection(face_edge_indexes[plane_index])
|
||||||
|
]
|
||||||
|
|
||||||
|
def same_inner_rotational_channel(first: int, second: int) -> bool:
|
||||||
|
"""Whether two inner rotational faces share one B-rep bore channel."""
|
||||||
|
if not face_edge_indexes[first].intersection(face_edge_indexes[second]):
|
||||||
|
return False
|
||||||
|
left, right = face_geometries[first], face_geometries[second]
|
||||||
|
if left.get("cylinder_role") != "inner" or right.get("cylinder_role") != "inner":
|
||||||
|
return False
|
||||||
|
left_axis, right_axis = left.get("axis_direction"), right.get("axis_direction")
|
||||||
|
left_origin, right_origin = left.get("axis_origin_mm"), right.get("axis_origin_mm")
|
||||||
|
if not all(isinstance(value, list) and len(value) == 3 for value in (left_axis, right_axis, left_origin, right_origin)):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
left_direction = tuple(float(value) for value in left_axis)
|
||||||
|
right_direction = tuple(float(value) for value in right_axis)
|
||||||
|
offset = tuple(float(left_origin[index]) - float(right_origin[index]) for index in range(3))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return False
|
||||||
|
alignment = sum(left_direction[index] * right_direction[index] for index in range(3))
|
||||||
|
if abs(alignment) < 1.0 - 1e-6:
|
||||||
|
return False
|
||||||
|
axial_offset = sum(offset[index] * left_direction[index] for index in range(3))
|
||||||
|
radial_offset = tuple(offset[index] - axial_offset * left_direction[index] for index in range(3))
|
||||||
|
return sum(value * value for value in radial_offset) ** 0.5 <= 1e-5
|
||||||
|
|
||||||
|
inner_rotational_indexes = [
|
||||||
|
index
|
||||||
|
for index, geometry in enumerate(face_geometries)
|
||||||
|
if geometry["surface_type"] in {"cylinder", "cone"} and geometry.get("cylinder_role") == "inner"
|
||||||
|
]
|
||||||
|
|
||||||
|
def channel_plane_indexes(start: int) -> list[int]:
|
||||||
|
"""Collect endpoint planes through joined, co-axial inner faces.
|
||||||
|
|
||||||
|
A countersink or counterbore splits a physical bore into a cone and
|
||||||
|
a cylinder. The cylinder has only one direct planar neighbour, so
|
||||||
|
direct adjacency alone cannot prove that the complete channel exits
|
||||||
|
the part. Traverse shared B-rep edges only across co-axial inner
|
||||||
|
rotational faces, then inspect the channel's actual plane ends.
|
||||||
|
"""
|
||||||
|
pending = [start]
|
||||||
|
visited: set[int] = set()
|
||||||
|
endpoints: set[int] = set()
|
||||||
|
while pending:
|
||||||
|
index = pending.pop()
|
||||||
|
if index in visited:
|
||||||
|
continue
|
||||||
|
visited.add(index)
|
||||||
|
endpoints.update(directly_linked_planes(index))
|
||||||
|
pending.extend(
|
||||||
|
candidate
|
||||||
|
for candidate in inner_rotational_indexes
|
||||||
|
if candidate not in visited and same_inner_rotational_channel(index, candidate)
|
||||||
|
)
|
||||||
|
return sorted(endpoints)
|
||||||
|
|
||||||
|
def spans_opposed_planes(linked: list[int], axis: Any) -> bool:
|
||||||
|
if not isinstance(axis, list) or len(axis) != 3:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
direction = tuple(float(value) for value in axis)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return False
|
||||||
|
return any(
|
||||||
|
sum(float(face_geometries[first]["normal"][component]) * float(face_geometries[second]["normal"][component]) for component in range(3)) <= -0.99
|
||||||
|
and all(abs(sum(float(face_geometries[position]["normal"][component]) * direction[component] for component in range(3))) >= 0.99 for position in (first, second))
|
||||||
|
for first in linked
|
||||||
|
for second in linked
|
||||||
|
if first < second
|
||||||
|
)
|
||||||
|
|
||||||
|
for index, geometry in enumerate(face_geometries):
|
||||||
|
if geometry["surface_type"] != "cylinder":
|
||||||
|
continue
|
||||||
|
linked = directly_linked_planes(index)
|
||||||
|
geometry["connected_plane_ids"] = [records[plane_index].record_id for plane_index in linked]
|
||||||
|
channel_linked = channel_plane_indexes(index) if geometry.get("cylinder_role") == "inner" else linked
|
||||||
|
geometry["channel_connected_plane_ids"] = [records[plane_index].record_id for plane_index in channel_linked]
|
||||||
|
geometry["through"] = spans_opposed_planes(channel_linked, geometry.get("axis_direction"))
|
||||||
# 4. 导出边记录:含包围盒、中心、长度、曲线类型与相邻面数;端点坐标可用时附加。
|
# 4. 导出边记录:含包围盒、中心、长度、曲线类型与相邻面数;端点坐标可用时附加。
|
||||||
for index, edge in enumerate(edges):
|
for index, edge in enumerate(edges):
|
||||||
bbox = edge.bounding_box()
|
bbox = edge.bounding_box()
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from pathlib import Path
|
|||||||
from typing import Any, Iterable
|
from typing import Any, Iterable
|
||||||
|
|
||||||
from .runtime_types import CapabilityResult, FeaturePlanNode, HoleSpec, RuntimeDiagnostic
|
from .runtime_types import CapabilityResult, FeaturePlanNode, HoleSpec, RuntimeDiagnostic
|
||||||
|
from .operation_contracts import materialized_feature_contracts
|
||||||
|
|
||||||
|
|
||||||
_SELECTOR_REQUIRED = frozenset({"fillet", "chamfer"})
|
_SELECTOR_REQUIRED = frozenset({"fillet", "chamfer"})
|
||||||
@@ -123,7 +124,7 @@ def pattern_transform_blocker(source: FeaturePlanNode) -> str | None:
|
|||||||
|
|
||||||
def _schema_contract() -> dict[str, dict[str, Any]]:
|
def _schema_contract() -> dict[str, dict[str, Any]]:
|
||||||
path = Path(__file__).with_name("profile_schema.json")
|
path = Path(__file__).with_name("profile_schema.json")
|
||||||
return json.loads(path.read_text(encoding="utf-8"))["feature_atomic_ids"]
|
return materialized_feature_contracts(json.loads(path.read_text(encoding="utf-8")))
|
||||||
|
|
||||||
|
|
||||||
def sketch_ids_required_by_contract(cdsl: dict[str, Any]) -> frozenset[str]:
|
def sketch_ids_required_by_contract(cdsl: dict[str, Any]) -> frozenset[str]:
|
||||||
|
|||||||
@@ -279,6 +279,25 @@
|
|||||||
],
|
],
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
},
|
},
|
||||||
|
"constructionBsplineSegment": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"type": {"const": "bspline"},
|
||||||
|
"degree": {"type": "integer", "minimum": 1},
|
||||||
|
"control_points": {"type": "array", "minItems": 2, "items": {"$ref": "#/$defs/point2"}},
|
||||||
|
"knots": {"type": "array", "minItems": 2, "items": {"type": "number"}},
|
||||||
|
"periodic": {"type": "boolean"},
|
||||||
|
"weights": {"type": "array", "minItems": 2, "items": {"type": "number", "exclusiveMinimum": 0}}
|
||||||
|
},
|
||||||
|
"required": ["type", "degree", "control_points", "knots", "periodic"],
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"constructionSegment": {
|
||||||
|
"oneOf": [
|
||||||
|
{"$ref": "#/$defs/analyticSegment"},
|
||||||
|
{"$ref": "#/$defs/constructionBsplineSegment"}
|
||||||
|
]
|
||||||
|
},
|
||||||
"analyticContour": {
|
"analyticContour": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -294,7 +313,7 @@
|
|||||||
"properties": {
|
"properties": {
|
||||||
"type": {"const": "analytic_contours"},
|
"type": {"const": "analytic_contours"},
|
||||||
"contours": {"type": "array", "items": {"$ref": "#/$defs/analyticContour"}},
|
"contours": {"type": "array", "items": {"$ref": "#/$defs/analyticContour"}},
|
||||||
"construction": {"type": "array", "items": {"$ref": "#/$defs/analyticSegment"}}
|
"construction": {"type": "array", "items": {"$ref": "#/$defs/constructionSegment"}}
|
||||||
},
|
},
|
||||||
"required": ["type", "contours"],
|
"required": ["type", "contours"],
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"""Derive engine-facing feature metadata from the v3 operation registry.
|
||||||
|
|
||||||
|
``profile_schema.json.operation_contracts`` is the only feature-operation
|
||||||
|
registry. The engine needs a compact view while validating materialized CDSL,
|
||||||
|
but that view is intentionally derived at load time instead of maintained as a
|
||||||
|
second hand-written ``feature_atomic_ids`` table.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
def materialized_feature_contracts(profile: dict[str, Any]) -> dict[str, dict[str, Any]]:
|
||||||
|
"""Return required engine metadata derived from each v3 operation contract.
|
||||||
|
|
||||||
|
Author-owned params are supplied by the tool schema. Server-injected
|
||||||
|
``params.*`` paths become required only after materialization, while
|
||||||
|
injected feature selectors are represented outside the params object.
|
||||||
|
"""
|
||||||
|
raw_contracts = profile.get("operation_contracts")
|
||||||
|
if not isinstance(raw_contracts, dict) or not raw_contracts:
|
||||||
|
raise ValueError("profile schema has no operation_contracts registry")
|
||||||
|
derived: dict[str, dict[str, Any]] = {}
|
||||||
|
for atomic_id, raw in raw_contracts.items():
|
||||||
|
if not isinstance(atomic_id, str) or not atomic_id or not isinstance(raw, dict):
|
||||||
|
raise ValueError("operation contract registry has an invalid entry")
|
||||||
|
if raw.get("atomic_id") != atomic_id:
|
||||||
|
raise ValueError(f"operation contract key and atomic_id disagree for {atomic_id}")
|
||||||
|
shape = raw.get("fragment_shape")
|
||||||
|
params_schema = raw.get("author_params_schema")
|
||||||
|
injected_paths = raw.get("server_injected_paths")
|
||||||
|
if not isinstance(shape, dict) or not isinstance(params_schema, dict) or not isinstance(injected_paths, list):
|
||||||
|
raise ValueError(f"operation contract is incomplete for {atomic_id}")
|
||||||
|
properties = params_schema.get("properties")
|
||||||
|
required = params_schema.get("required")
|
||||||
|
if not isinstance(properties, dict) or not isinstance(required, list):
|
||||||
|
raise ValueError(f"operation author params are invalid for {atomic_id}")
|
||||||
|
if not all(isinstance(name, str) and name in properties for name in required):
|
||||||
|
raise ValueError(f"operation required params are invalid for {atomic_id}")
|
||||||
|
materialized_required = list(dict.fromkeys(required))
|
||||||
|
for path in injected_paths:
|
||||||
|
if not isinstance(path, str):
|
||||||
|
raise ValueError(f"operation injection path is invalid for {atomic_id}")
|
||||||
|
if path.startswith("params.") and path.count(".") == 1:
|
||||||
|
materialized_required.append(path.removeprefix("params."))
|
||||||
|
elif path != "feature.selectors":
|
||||||
|
raise ValueError(f"operation injection path is unsupported for {atomic_id}")
|
||||||
|
materialized_required = list(dict.fromkeys(materialized_required))
|
||||||
|
derived[atomic_id] = {
|
||||||
|
"required_params": materialized_required,
|
||||||
|
"optional_params": [name for name in properties if name not in materialized_required],
|
||||||
|
"requires_sketch": shape.get("sketch") == "required",
|
||||||
|
}
|
||||||
|
return derived
|
||||||
@@ -82,13 +82,19 @@ def is_static_phase_ready(cdsl: dict[str, Any], phase: str) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def select_static_phase_pool(cdsl_dir: Path, phase: str) -> list[str]:
|
def select_static_phase_pool(cdsl_dir: Path, phase: str) -> list[str]:
|
||||||
"""Return sorted part ids in one documented static phase input pool."""
|
"""Return sorted CDSL file-stem selectors for one static phase pool.
|
||||||
part_ids: list[str] = []
|
|
||||||
|
Batch rebuild selection is keyed by the immutable filename rather than the
|
||||||
|
document's display ``part_id``. Imports retain the latter as source
|
||||||
|
provenance and it may differ only by case or normalisation, which makes it
|
||||||
|
unsuitable as a unique filesystem selector.
|
||||||
|
"""
|
||||||
|
selectors: list[str] = []
|
||||||
for path in sorted(cdsl_dir.glob("*.cdsl.json")):
|
for path in sorted(cdsl_dir.glob("*.cdsl.json")):
|
||||||
document: dict[str, Any] = json.loads(path.read_text(encoding="utf-8"))
|
document: dict[str, Any] = json.loads(path.read_text(encoding="utf-8"))
|
||||||
if is_static_phase_ready(document, phase):
|
if is_static_phase_ready(document, phase):
|
||||||
part_ids.append(str(document.get("part_id") or path.name.removesuffix(".cdsl.json")))
|
selectors.append(path.name.removesuffix(".cdsl.json"))
|
||||||
return part_ids
|
return selectors
|
||||||
|
|
||||||
|
|
||||||
def is_p3_static_ready(cdsl: dict[str, Any]) -> bool:
|
def is_p3_static_ready(cdsl: dict[str, Any]) -> bool:
|
||||||
|
|||||||
@@ -1,28 +1,56 @@
|
|||||||
{
|
{
|
||||||
"schema": "cdsl.engine.schema.v1",
|
"schema": "cdsl.engine.schema.v1",
|
||||||
"schema_version": "1.3.0",
|
"schema_version": "1.3.1",
|
||||||
"cdsl_json_schema_file": "cdsl_schema.json",
|
"cdsl_json_schema_file": "cdsl_schema.json",
|
||||||
"maintenance_rule": "The CDSL-only runtime contract is limited to direct generic profiles and runtime.py EXECUTORS. Legacy macro profiles are importer compatibility syntax and must be lowered by cdsl_importer.legacy_profile_adapter before generic runtime validation.",
|
"maintenance_rule": "The CDSL-only runtime contract is limited to direct generic profiles and runtime.py EXECUTORS. Legacy macro profiles are importer compatibility syntax and must be lowered by cdsl_importer.legacy_profile_adapter before generic runtime validation.",
|
||||||
"coordinate_convention": "All profile dimensions use millimetres. Two-dimensional points are [u, v] in the sketch workplane.",
|
"coordinate_convention": "All profile dimensions use millimetres. Two-dimensional points are [u, v] in the sketch workplane.",
|
||||||
"runtime_supported_atomic_ids": ["extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind", "revolve_add", "revolve_cut", "hole_blind", "hole_countersink", "hole_counterbore", "sphere_add", "reference_plane", "reference_axis", "hole_wizard", "fillet", "chamfer", "pattern_linear", "pattern_mirror"],
|
|
||||||
"runtime_supported_profiles": ["circle", "polygon", "analytic_contours"],
|
"runtime_supported_profiles": ["circle", "polygon", "analytic_contours"],
|
||||||
"feature_atomic_ids": {
|
"operation_contracts": {
|
||||||
"extrude_add_blind": {"summary": "Add the closed profile by one signed extrusion distance.", "required_params": ["distance_mm"], "optional_params": ["reverse"], "requires_sketch": true, "produces_body": true},
|
"extrude_add_blind": {"atomic_id":"extrude_add_blind","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"reverse":{"type":"boolean"}},"required":["distance_mm"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["sketch_workplane","profile_non_self_intersecting"],"candidate_verifiers":["single_connected_body"]},
|
||||||
"extrude_add_two_sided": {"summary": "Add the closed profile with independently captured forward and reverse terminations.", "required_params": ["distance_mm", "reverse_distance_mm"], "optional_params": ["reverse", "end_condition", "reverse_end_condition"], "requires_sketch": true, "produces_body": true},
|
"extrude_add_two_sided": {"atomic_id":"extrude_add_two_sided","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"reverse_distance_mm":{"type":"number","exclusiveMinimum":0},"reverse":{"type":"boolean"},"end_condition":{"type":"object","properties":{"type":{"type":"string","minLength":1,"maxLength":48},"solidworks_code":{"type":"integer"}},"required":["type","solidworks_code"],"additionalProperties":false},"reverse_end_condition":{"type":"object","properties":{"type":{"type":"string","minLength":1,"maxLength":48},"solidworks_code":{"type":"integer"}},"required":["type","solidworks_code"],"additionalProperties":false}},"required":["distance_mm","reverse_distance_mm"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["sketch_workplane","profile_non_self_intersecting"],"candidate_verifiers":["single_connected_body"]},
|
||||||
"extrude_cut_blind": {"summary": "Remove the closed profile by one signed extrusion distance.", "required_params": ["distance_mm"], "optional_params": ["reverse"], "requires_sketch": true},
|
"extrude_cut_blind": {"atomic_id":"extrude_cut_blind","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"reverse":{"type":"boolean"}},"required":["distance_mm"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["requires_active_solid","sketch_workplane","profile_non_self_intersecting","cut_exit_distance"],"candidate_verifiers":["single_connected_body","volume_decreased"]},
|
||||||
"revolve_add": {"summary": "Add the closed profile by revolving it around an axis.", "required_params": ["angle_deg", "axis"], "optional_params": ["reverse"], "requires_sketch": true, "produces_body": true},
|
"revolve_add": {"atomic_id":"revolve_add","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"angle_deg":{"type":"number","exclusiveMinimum":0,"maximum":360},"axis":{"type":"object","properties":{"origin_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"direction":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["origin_mm","direction"],"additionalProperties":false},"reverse":{"type":"boolean"}},"required":["angle_deg","axis"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["sketch_workplane","revolve_axis_on_sketch"],"candidate_verifiers":["single_connected_body"]},
|
||||||
"revolve_cut": {"summary": "Remove the closed profile by revolving it around an axis.", "required_params": ["angle_deg", "axis"], "optional_params": ["reverse"], "requires_sketch": true},
|
"revolve_cut": {"atomic_id":"revolve_cut","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"angle_deg":{"type":"number","exclusiveMinimum":0,"maximum":360},"axis":{"type":"object","properties":{"origin_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"direction":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["origin_mm","direction"],"additionalProperties":false},"reverse":{"type":"boolean"}},"required":["angle_deg","axis"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["requires_active_solid","sketch_workplane","revolve_axis_on_sketch"],"candidate_verifiers":["single_connected_body","volume_decreased"]},
|
||||||
"hole_blind": {"summary": "Cut one or more blind cylindrical holes in the current body.", "required_params": ["diameter_mm", "depth_mm", "positions", "host_face"], "optional_params": ["drill_angle_rad"], "position_format": "positions is a non-empty array of objects: [{\"mm\":[u_mm,v_mm,w_mm]}]. A bare coordinate array is invalid. host_face is injected from exactly one face selector token.", "requires_sketch": false},
|
"hole_blind": {"atomic_id":"hole_blind","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"diameter_mm":{"type":"number","exclusiveMinimum":0},"depth_mm":{"type":"number","exclusiveMinimum":0},"positions":{"type":"array","minItems":1,"maxItems":64,"items":{"type":"object","properties":{"mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["mm"],"additionalProperties":false}},"drill_angle_rad":{"type":"number","exclusiveMinimum":0,"maximum":3.141592653589793}},"required":["diameter_mm","depth_mm","positions"],"additionalProperties":false},"selector_policy":{"slot":"params.host_face","token_kind":"face","min_items":1,"max_items":1,"snapshot_bound":true},"server_injected_paths":["params.host_face"],"reference_policy":{"mode":"none"},"semantic_preflight":["host_face_exists","hole_positions_on_host_plane","cut_exit_distance"],"candidate_verifiers":["cylindrical_bore","through_cylindrical_bore"]},
|
||||||
"hole_countersink": {"summary": "Cut one or more blind holes with countersink dimensions.", "required_params": ["diameter_mm", "depth_mm", "positions", "countersink_diameter_mm", "countersink_angle_rad", "host_face"], "optional_params": ["drill_angle_rad"], "position_format": "positions is a non-empty array of objects: [{\"mm\":[u_mm,v_mm,w_mm]}]. A bare coordinate array is invalid. host_face is injected from exactly one face selector token.", "requires_sketch": false},
|
"hole_countersink": {"atomic_id":"hole_countersink","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"diameter_mm":{"type":"number","exclusiveMinimum":0},"depth_mm":{"type":"number","exclusiveMinimum":0},"positions":{"type":"array","minItems":1,"maxItems":64,"items":{"type":"object","properties":{"mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["mm"],"additionalProperties":false}},"countersink_diameter_mm":{"type":"number","exclusiveMinimum":0},"countersink_angle_rad":{"type":"number","exclusiveMinimum":0,"maximum":3.141592653589793},"drill_angle_rad":{"type":"number","exclusiveMinimum":0,"maximum":3.141592653589793}},"required":["diameter_mm","depth_mm","positions","countersink_diameter_mm","countersink_angle_rad"],"additionalProperties":false},"selector_policy":{"slot":"params.host_face","token_kind":"face","min_items":1,"max_items":1,"snapshot_bound":true},"server_injected_paths":["params.host_face"],"reference_policy":{"mode":"none"},"semantic_preflight":["host_face_exists","hole_positions_on_host_plane","cut_exit_distance"],"candidate_verifiers":["cylindrical_bore"]},
|
||||||
"hole_counterbore": {"summary": "Cut one or more blind holes with counterbore dimensions.", "required_params": ["diameter_mm", "depth_mm", "positions", "counterbore_diameter_mm", "counterbore_depth_mm", "host_face"], "optional_params": ["drill_angle_rad"], "position_format": "positions is a non-empty array of objects: [{\"mm\":[u_mm,v_mm,w_mm]}]. A bare coordinate array is invalid. host_face is injected from exactly one face selector token.", "requires_sketch": false},
|
"hole_counterbore": {"atomic_id":"hole_counterbore","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"diameter_mm":{"type":"number","exclusiveMinimum":0},"depth_mm":{"type":"number","exclusiveMinimum":0},"positions":{"type":"array","minItems":1,"maxItems":64,"items":{"type":"object","properties":{"mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["mm"],"additionalProperties":false}},"counterbore_diameter_mm":{"type":"number","exclusiveMinimum":0},"counterbore_depth_mm":{"type":"number","exclusiveMinimum":0},"drill_angle_rad":{"type":"number","exclusiveMinimum":0,"maximum":3.141592653589793}},"required":["diameter_mm","depth_mm","positions","counterbore_diameter_mm","counterbore_depth_mm"],"additionalProperties":false},"selector_policy":{"slot":"params.host_face","token_kind":"face","min_items":1,"max_items":1,"snapshot_bound":true},"server_injected_paths":["params.host_face"],"reference_policy":{"mode":"none"},"semantic_preflight":["host_face_exists","hole_positions_on_host_plane","cut_exit_distance"],"candidate_verifiers":["cylindrical_bore"]},
|
||||||
"sphere_add": {"summary": "Add one spherical solid at an explicit model-space center.", "required_params": ["radius_mm", "center_mm"], "optional_params": [], "requires_sketch": false, "produces_body": true},
|
"sphere_add": {"atomic_id":"sphere_add","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"radius_mm":{"type":"number","exclusiveMinimum":0},"center_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["radius_mm","center_mm"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":[],"candidate_verifiers":["single_connected_body"]},
|
||||||
"fillet": {"summary": "Apply a radius to selected edges or faces.", "required_params": ["radius_mm"], "optional_params": ["tangent_propagation"], "requires_sketch": false, "selector_slot": {"path": "feature.selectors", "min_items": 1, "max_items": 64}},
|
"reference_plane": {"atomic_id":"reference_plane","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"plane":{"type":"object","properties":{"origin_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"x_dir":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"normal":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["origin_mm","x_dir","normal"],"additionalProperties":false}},"required":["plane"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["requires_active_solid","reference_plane_nonzero_normal"],"candidate_verifiers":[]},
|
||||||
"chamfer": {"summary": "Apply an equal-distance or angle-distance chamfer to selected edges or faces.", "required_params": ["distance_mm"], "optional_params": ["distance_2_mm", "angle_rad"], "requires_sketch": false, "selector_slot": {"path": "feature.selectors", "min_items": 1, "max_items": 64}},
|
"reference_axis": {"atomic_id":"reference_axis","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"axis":{"type":"object","properties":{"origin_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"direction":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["origin_mm","direction"],"additionalProperties":false}},"required":["axis"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["requires_active_solid","reference_axis_nonzero_direction"],"candidate_verifiers":[]},
|
||||||
"pattern_linear": {"summary": "Repeat source features along one or two directions.", "required_params": ["source_feature_ids", "direction_1", "spacing_1_mm", "pattern_count_1"], "optional_params": ["direction_2", "spacing_2_mm", "pattern_count_2"], "requires_sketch": false},
|
"hole_wizard": {
|
||||||
"pattern_mirror": {"summary": "Mirror source features about a selected plane.", "required_params": ["source_feature_ids", "mirror_plane"], "optional_params": [], "requires_sketch": false, "selector_slot": {"path": "params.mirror_plane", "min_items": 1, "max_items": 1}},
|
"atomic_id": "hole_wizard",
|
||||||
"reference_plane": {"summary": "A named reference plane used by sketches or patterns.", "required_params": ["plane"], "optional_params": [], "requires_sketch": false},
|
"contract_version": "3.0",
|
||||||
"reference_axis": {"summary": "A named reference axis used by revolve or pattern features.", "required_params": ["axis"], "optional_params": [], "requires_sketch": false},
|
"fragment_shape": {"sketch": "forbidden", "params": "required_object", "selector_tokens": "required"},
|
||||||
"hole_wizard": {"summary": "A SolidWorks Hole Wizard feature including its typed dimensional contract and placement selectors.", "required_params": ["hole_type", "diameter_mm", "depth_mm"], "optional_params": ["positions", "host_face", "thread", "countersink", "counterbore"], "requires_sketch": false, "selector_slot": {"path": "params.host_face", "min_items": 1, "max_items": 1}}
|
"author_params_schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"hole_type": {"enum": ["simple", "threaded", "countersink", "counterbore"]},
|
||||||
|
"diameter_mm": {"type": "number", "exclusiveMinimum": 0},
|
||||||
|
"depth_mm": {"type": "number", "exclusiveMinimum": 0},
|
||||||
|
"end_condition": {"type": "object", "properties": {"type": {"enum": ["blind", "through_all", "through_all_both"]}, "solidworks_code": {"type": "integer"}}, "required": ["type", "solidworks_code"], "additionalProperties": false},
|
||||||
|
"positions": {"type": "array", "minItems": 1, "maxItems": 64, "items": {"type": "object", "properties": {"mm": {"type": "array", "items": {"type": "number"}, "minItems": 3, "maxItems": 3}}, "required": ["mm"], "additionalProperties": false}},
|
||||||
|
"thread": {"type": "object", "properties": {"designation": {"type": "string", "minLength": 1, "maxLength": 80}, "pitch_mm": {"type": "number", "exclusiveMinimum": 0}}, "required": ["designation", "pitch_mm"], "additionalProperties": false},
|
||||||
|
"countersink": {"type": "object", "properties": {"diameter_mm": {"type": "number", "exclusiveMinimum": 0}, "angle_rad": {"type": "number", "exclusiveMinimum": 0, "maximum": 3.141592653589793}}, "required": ["diameter_mm", "angle_rad"], "additionalProperties": false},
|
||||||
|
"counterbore": {"type": "object", "properties": {"diameter_mm": {"type": "number", "exclusiveMinimum": 0}, "depth_mm": {"type": "number", "exclusiveMinimum": 0}}, "required": ["diameter_mm", "depth_mm"], "additionalProperties": false}
|
||||||
|
},
|
||||||
|
"required": ["hole_type", "diameter_mm", "depth_mm", "end_condition", "positions"],
|
||||||
|
"allOf": [
|
||||||
|
{"if": {"properties": {"hole_type": {"const": "threaded"}}, "required": ["hole_type"]}, "then": {"required": ["thread"]}},
|
||||||
|
{"if": {"properties": {"hole_type": {"const": "countersink"}}, "required": ["hole_type"]}, "then": {"required": ["countersink"]}},
|
||||||
|
{"if": {"properties": {"hole_type": {"const": "counterbore"}}, "required": ["hole_type"]}, "then": {"required": ["counterbore"]}}
|
||||||
|
],
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"selector_policy": {"slot": "params.host_face", "token_kind": "face", "min_items": 1, "max_items": 1, "snapshot_bound": true},
|
||||||
|
"server_injected_paths": ["params.host_face"],
|
||||||
|
"reference_policy": {"mode": "none"},
|
||||||
|
"semantic_preflight": ["host_face_exists", "hole_positions_on_host_plane"],
|
||||||
|
"candidate_verifiers": ["cylindrical_bore"]
|
||||||
|
},
|
||||||
|
"fillet": {"atomic_id":"fillet","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"radius_mm":{"type":"number","exclusiveMinimum":0},"tangent_propagation":{"type":"boolean"}},"required":["radius_mm"],"additionalProperties":false},"selector_policy":{"slot":"feature.selectors","token_kind":"edge","min_items":1,"max_items":64,"snapshot_bound":true},"server_injected_paths":["feature.selectors"],"reference_policy":{"mode":"none"},"semantic_preflight":["selected_edges_exist"],"candidate_verifiers":["single_connected_body"]},
|
||||||
|
"chamfer": {"atomic_id":"chamfer","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"distance_2_mm":{"type":"number","exclusiveMinimum":0},"angle_rad":{"type":"number","exclusiveMinimum":0,"maximum":3.141592653589793}},"required":["distance_mm"],"additionalProperties":false},"selector_policy":{"slot":"feature.selectors","token_kind":"edge","min_items":1,"max_items":64,"snapshot_bound":true},"server_injected_paths":["feature.selectors"],"reference_policy":{"mode":"none"},"semantic_preflight":["selected_edges_exist"],"candidate_verifiers":["single_connected_body"]},
|
||||||
|
"pattern_linear": {"atomic_id":"pattern_linear","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"source_feature_ids":{"type":"array","items":{"type":"string","pattern":"^[a-z][a-z0-9_:-]{0,95}$"},"minItems":1,"maxItems":16,"uniqueItems":true},"direction_1":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"spacing_1_mm":{"type":"number","exclusiveMinimum":0},"pattern_count_1":{"type":"integer","minimum":1,"maximum":128},"direction_2":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"spacing_2_mm":{"type":"number","exclusiveMinimum":0},"pattern_count_2":{"type":"integer","minimum":1,"maximum":128}},"required":["source_feature_ids","direction_1","spacing_1_mm","pattern_count_1"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"snapshot_bound","slot":"params.source_feature_ids","token_kind":"feature","min_items":1,"max_items":16,"snapshot_bound":true},"semantic_preflight":["source_features_exist"],"candidate_verifiers":[]},
|
||||||
|
"pattern_mirror": {"atomic_id":"pattern_mirror","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"source_feature_ids":{"type":"array","items":{"type":"string","pattern":"^[a-z][a-z0-9_:-]{0,95}$"},"minItems":1,"maxItems":16,"uniqueItems":true}},"required":["source_feature_ids"],"additionalProperties":false},"selector_policy":{"slot":"params.mirror_plane","token_kind":"plane","min_items":1,"max_items":1,"snapshot_bound":true},"server_injected_paths":["params.mirror_plane"],"reference_policy":{"mode":"snapshot_bound","slot":"params.source_feature_ids","token_kind":"feature","min_items":1,"max_items":16,"snapshot_bound":true},"semantic_preflight":["source_features_exist","mirror_plane_exists"],"candidate_verifiers":[]}
|
||||||
},
|
},
|
||||||
"profiles": {
|
"profiles": {
|
||||||
"circle": {
|
"circle": {
|
||||||
@@ -37,7 +65,7 @@
|
|||||||
"constraints": ["vertices contains at least three [u, v] points"]
|
"constraints": ["vertices contains at least three [u, v] points"]
|
||||||
},
|
},
|
||||||
"analytic_contours": {
|
"analytic_contours": {
|
||||||
"summary": "Closed direct line, arc and circle contours. B-splines are unsupported."
|
"summary": "Closed executable line, arc and circle contours. B-splines are unsupported in contours; imported construction B-splines are retained as non-executable audit geometry."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,199 @@
|
|||||||
|
{
|
||||||
|
"status": "LIVE_EVAL_BLOCKED",
|
||||||
|
"error": "MODEL_STRUCTURED_OUTPUT_UNSUPPORTED",
|
||||||
|
"author_capability": {
|
||||||
|
"schema_hash": "eef41948f13431f329c42f49d7d94477668f4ab8fbe115c4e26045cd7a7c680b",
|
||||||
|
"tool_count": 21,
|
||||||
|
"supported": false,
|
||||||
|
"failures": [
|
||||||
|
{
|
||||||
|
"tool": "submit_requirements_draft_batch",
|
||||||
|
"message": "Provider transport unavailable after 3 attempts: All connection attempts failed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "review_requirements",
|
||||||
|
"message": "Provider transport unavailable after 3 attempts: All connection attempts failed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "propose_next_action",
|
||||||
|
"message": "Provider transport unavailable after 3 attempts: All connection attempts failed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "review_candidate",
|
||||||
|
"message": "Provider transport unavailable after 3 attempts: All connection attempts failed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "review_final",
|
||||||
|
"message": "Provider transport unavailable after 3 attempts: All connection attempts failed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_chamfer",
|
||||||
|
"message": "Provider transport unavailable after 3 attempts: All connection attempts failed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_extrude_add_blind",
|
||||||
|
"message": "Provider transport unavailable after 3 attempts: All connection attempts failed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_extrude_add_two_sided",
|
||||||
|
"message": "Provider transport unavailable after 3 attempts: All connection attempts failed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_extrude_cut_blind",
|
||||||
|
"message": "Provider transport unavailable after 3 attempts: All connection attempts failed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_fillet",
|
||||||
|
"message": "Provider transport unavailable after 3 attempts: All connection attempts failed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_hole_blind",
|
||||||
|
"message": "Provider transport unavailable after 3 attempts: All connection attempts failed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_hole_counterbore",
|
||||||
|
"message": "Provider transport unavailable after 3 attempts: All connection attempts failed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_hole_countersink",
|
||||||
|
"message": "Provider transport unavailable after 3 attempts: All connection attempts failed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_hole_wizard",
|
||||||
|
"message": "Provider transport unavailable after 3 attempts: All connection attempts failed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_pattern_linear",
|
||||||
|
"message": "Provider transport unavailable after 3 attempts: All connection attempts failed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_pattern_mirror",
|
||||||
|
"message": "Provider transport unavailable after 3 attempts: All connection attempts failed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_reference_axis",
|
||||||
|
"message": "Provider transport unavailable after 3 attempts: All connection attempts failed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_reference_plane",
|
||||||
|
"message": "Provider transport unavailable after 3 attempts: All connection attempts failed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_revolve_add",
|
||||||
|
"message": "Provider transport unavailable after 3 attempts: All connection attempts failed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_revolve_cut",
|
||||||
|
"message": "Provider transport unavailable after 3 attempts: All connection attempts failed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_sphere_add",
|
||||||
|
"message": "Provider transport unavailable after 3 attempts: All connection attempts failed"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"usage": {
|
||||||
|
"prompt_tokens": 0,
|
||||||
|
"completion_tokens": 0
|
||||||
|
},
|
||||||
|
"mode": "openai_compatible_tool_calling"
|
||||||
|
},
|
||||||
|
"reviewer_capability": {
|
||||||
|
"schema_hash": "eef41948f13431f329c42f49d7d94477668f4ab8fbe115c4e26045cd7a7c680b",
|
||||||
|
"tool_count": 21,
|
||||||
|
"supported": false,
|
||||||
|
"failures": [
|
||||||
|
{
|
||||||
|
"tool": "submit_requirements_draft_batch",
|
||||||
|
"message": "Provider transport unavailable after 3 attempts: All connection attempts failed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "review_requirements",
|
||||||
|
"message": "Provider transport unavailable after 3 attempts: All connection attempts failed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "propose_next_action",
|
||||||
|
"message": "Provider transport unavailable after 3 attempts: All connection attempts failed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "review_candidate",
|
||||||
|
"message": "Provider transport unavailable after 3 attempts: All connection attempts failed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "review_final",
|
||||||
|
"message": "Provider transport unavailable after 3 attempts: All connection attempts failed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_chamfer",
|
||||||
|
"message": "Provider transport unavailable after 3 attempts: All connection attempts failed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_extrude_add_blind",
|
||||||
|
"message": "Provider transport unavailable after 3 attempts: All connection attempts failed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_extrude_add_two_sided",
|
||||||
|
"message": "Provider transport unavailable after 3 attempts: All connection attempts failed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_extrude_cut_blind",
|
||||||
|
"message": "Provider transport unavailable after 3 attempts: All connection attempts failed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_fillet",
|
||||||
|
"message": "Provider transport unavailable after 3 attempts: All connection attempts failed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_hole_blind",
|
||||||
|
"message": "Provider transport unavailable after 3 attempts: All connection attempts failed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_hole_counterbore",
|
||||||
|
"message": "Provider transport unavailable after 3 attempts: All connection attempts failed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_hole_countersink",
|
||||||
|
"message": "Provider transport unavailable after 3 attempts: All connection attempts failed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_hole_wizard",
|
||||||
|
"message": "Provider transport unavailable after 3 attempts: All connection attempts failed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_pattern_linear",
|
||||||
|
"message": "Provider transport unavailable after 3 attempts: All connection attempts failed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_pattern_mirror",
|
||||||
|
"message": "Provider transport unavailable after 3 attempts: All connection attempts failed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_reference_axis",
|
||||||
|
"message": "Provider transport unavailable after 3 attempts: All connection attempts failed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_reference_plane",
|
||||||
|
"message": "Provider transport unavailable after 3 attempts: All connection attempts failed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_revolve_add",
|
||||||
|
"message": "Provider transport unavailable after 3 attempts: All connection attempts failed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_revolve_cut",
|
||||||
|
"message": "Provider transport unavailable after 3 attempts: All connection attempts failed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_sphere_add",
|
||||||
|
"message": "Provider transport unavailable after 3 attempts: All connection attempts failed"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"usage": {
|
||||||
|
"prompt_tokens": 0,
|
||||||
|
"completion_tokens": 0
|
||||||
|
},
|
||||||
|
"mode": "openai_compatible_tool_calling"
|
||||||
|
},
|
||||||
|
"suite": "smoke",
|
||||||
|
"require_live": true,
|
||||||
|
"report_root": "/Users/lk/Projects/cdsl-cad/backend/live-evals/20260831T075523Z"
|
||||||
|
}
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
{
|
||||||
|
"status": "LIVE_EVAL_BLOCKED",
|
||||||
|
"error": "MODEL_STRUCTURED_OUTPUT_UNSUPPORTED",
|
||||||
|
"author_capability": {
|
||||||
|
"schema_hash": "08f70b6c1f06c0c12b3e5122a7c2d1d4077c07ceb397a8ffad05089714bd2fdf",
|
||||||
|
"tool_count": 21,
|
||||||
|
"supported": false,
|
||||||
|
"failures": [
|
||||||
|
{
|
||||||
|
"tool": "submit_requirements_draft_batch",
|
||||||
|
"message": "Provider transport unavailable after 3 attempts: All connection attempts failed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "review_requirements",
|
||||||
|
"message": "Provider transport unavailable after 3 attempts: All connection attempts failed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "propose_next_action",
|
||||||
|
"message": "Provider transport unavailable after 3 attempts: All connection attempts failed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "review_candidate",
|
||||||
|
"message": "Provider transport unavailable after 3 attempts: All connection attempts failed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "review_final",
|
||||||
|
"message": "Provider transport unavailable after 3 attempts: All connection attempts failed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_chamfer",
|
||||||
|
"message": "Provider transport unavailable after 3 attempts: All connection attempts failed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_extrude_add_blind",
|
||||||
|
"message": "Provider transport unavailable after 3 attempts: All connection attempts failed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_extrude_add_two_sided",
|
||||||
|
"message": "Provider transport unavailable after 3 attempts: All connection attempts failed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_extrude_cut_blind",
|
||||||
|
"message": "Provider transport unavailable after 3 attempts: All connection attempts failed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_fillet",
|
||||||
|
"message": "Provider transport unavailable after 3 attempts: All connection attempts failed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_hole_blind",
|
||||||
|
"message": "Provider transport unavailable after 3 attempts: All connection attempts failed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_hole_counterbore",
|
||||||
|
"message": "Provider transport unavailable after 3 attempts: All connection attempts failed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_hole_countersink",
|
||||||
|
"message": "Provider transport unavailable after 3 attempts: All connection attempts failed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_hole_wizard",
|
||||||
|
"message": "Provider transport unavailable after 3 attempts: All connection attempts failed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_pattern_linear",
|
||||||
|
"message": "Provider transport unavailable after 3 attempts: All connection attempts failed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_pattern_mirror",
|
||||||
|
"message": "Provider transport unavailable after 3 attempts: All connection attempts failed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_reference_axis",
|
||||||
|
"message": "Provider transport unavailable after 3 attempts: All connection attempts failed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_reference_plane",
|
||||||
|
"message": "Provider transport unavailable after 3 attempts: All connection attempts failed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_revolve_add",
|
||||||
|
"message": "Provider transport unavailable after 3 attempts: All connection attempts failed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_revolve_cut",
|
||||||
|
"message": "Provider transport unavailable after 3 attempts: All connection attempts failed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_sphere_add",
|
||||||
|
"message": "Provider transport unavailable after 3 attempts: All connection attempts failed"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"usage": {
|
||||||
|
"prompt_tokens": 0,
|
||||||
|
"completion_tokens": 0
|
||||||
|
},
|
||||||
|
"mode": "openai_compatible_tool_calling"
|
||||||
|
},
|
||||||
|
"reviewer_capability": {
|
||||||
|
"schema_hash": "08f70b6c1f06c0c12b3e5122a7c2d1d4077c07ceb397a8ffad05089714bd2fdf",
|
||||||
|
"tool_count": 21,
|
||||||
|
"supported": false,
|
||||||
|
"failures": [
|
||||||
|
{
|
||||||
|
"tool": "submit_requirements_draft_batch",
|
||||||
|
"message": "Provider transport unavailable after 3 attempts: All connection attempts failed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "review_requirements",
|
||||||
|
"message": "Provider transport unavailable after 3 attempts: All connection attempts failed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "propose_next_action",
|
||||||
|
"message": "Provider transport unavailable after 3 attempts: All connection attempts failed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "review_candidate",
|
||||||
|
"message": "Provider transport unavailable after 3 attempts: All connection attempts failed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "review_final",
|
||||||
|
"message": "Provider transport unavailable after 3 attempts: All connection attempts failed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_chamfer",
|
||||||
|
"message": "Provider transport unavailable after 3 attempts: All connection attempts failed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_extrude_add_blind",
|
||||||
|
"message": "Provider transport unavailable after 3 attempts: All connection attempts failed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_extrude_add_two_sided",
|
||||||
|
"message": "Provider transport unavailable after 3 attempts: All connection attempts failed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_extrude_cut_blind",
|
||||||
|
"message": "Provider transport unavailable after 3 attempts: All connection attempts failed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_fillet",
|
||||||
|
"message": "Provider transport unavailable after 3 attempts: All connection attempts failed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_hole_blind",
|
||||||
|
"message": "Provider transport unavailable after 3 attempts: All connection attempts failed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_hole_counterbore",
|
||||||
|
"message": "Provider transport unavailable after 3 attempts: All connection attempts failed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_hole_countersink",
|
||||||
|
"message": "Provider transport unavailable after 3 attempts: All connection attempts failed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_hole_wizard",
|
||||||
|
"message": "Provider transport unavailable after 3 attempts: All connection attempts failed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_pattern_linear",
|
||||||
|
"message": "Provider transport unavailable after 3 attempts: All connection attempts failed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_pattern_mirror",
|
||||||
|
"message": "Provider transport unavailable after 3 attempts: All connection attempts failed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_reference_axis",
|
||||||
|
"message": "Provider transport unavailable after 3 attempts: All connection attempts failed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_reference_plane",
|
||||||
|
"message": "Provider transport unavailable after 3 attempts: All connection attempts failed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_revolve_add",
|
||||||
|
"message": "Provider transport unavailable after 3 attempts: All connection attempts failed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_revolve_cut",
|
||||||
|
"message": "Provider transport unavailable after 3 attempts: All connection attempts failed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_sphere_add",
|
||||||
|
"message": "Provider transport unavailable after 3 attempts: All connection attempts failed"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"usage": {
|
||||||
|
"prompt_tokens": 0,
|
||||||
|
"completion_tokens": 0
|
||||||
|
},
|
||||||
|
"mode": "openai_compatible_tool_calling"
|
||||||
|
},
|
||||||
|
"suite": "smoke",
|
||||||
|
"require_live": true,
|
||||||
|
"report_root": "/Users/lk/Projects/cdsl-cad/backend/live-evals/20260831T082314Z"
|
||||||
|
}
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
{
|
||||||
|
"status": "LIVE_EVAL_BLOCKED",
|
||||||
|
"error": "MODEL_STRUCTURED_OUTPUT_UNSUPPORTED",
|
||||||
|
"author_capability": {
|
||||||
|
"schema_hash": "08f70b6c1f06c0c12b3e5122a7c2d1d4077c07ceb397a8ffad05089714bd2fdf",
|
||||||
|
"tool_count": 21,
|
||||||
|
"supported": false,
|
||||||
|
"failures": [
|
||||||
|
{
|
||||||
|
"tool": "submit_requirements_draft_batch",
|
||||||
|
"message": "Provider rejected structured request (400): {\"error\":{\"message\":\"One of \\\"input\\\" or \\\"previous_response_id\\\" or 'prompt' or 'conversation' must be provided.\",\"type\":\"invalid_request_error\"}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "review_requirements",
|
||||||
|
"message": "Provider rejected structured request (400): {\"error\":{\"message\":\"One of \\\"input\\\" or \\\"previous_response_id\\\" or 'prompt' or 'conversation' must be provided.\",\"type\":\"invalid_request_error\"}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "propose_next_action",
|
||||||
|
"message": "Provider rejected structured request (400): {\"error\":{\"message\":\"One of \\\"input\\\" or \\\"previous_response_id\\\" or 'prompt' or 'conversation' must be provided.\",\"type\":\"invalid_request_error\"}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "review_candidate",
|
||||||
|
"message": "Provider rejected structured request (400): {\"error\":{\"message\":\"One of \\\"input\\\" or \\\"previous_response_id\\\" or 'prompt' or 'conversation' must be provided.\",\"type\":\"invalid_request_error\"}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "review_final",
|
||||||
|
"message": "Provider rejected structured request (400): {\"error\":{\"message\":\"One of \\\"input\\\" or \\\"previous_response_id\\\" or 'prompt' or 'conversation' must be provided.\",\"type\":\"invalid_request_error\"}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_chamfer",
|
||||||
|
"message": "Provider rejected structured request (400): {\"error\":{\"message\":\"One of \\\"input\\\" or \\\"previous_response_id\\\" or 'prompt' or 'conversation' must be provided.\",\"type\":\"invalid_request_error\"}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_extrude_add_blind",
|
||||||
|
"message": "Provider rejected structured request (400): {\"error\":{\"message\":\"One of \\\"input\\\" or \\\"previous_response_id\\\" or 'prompt' or 'conversation' must be provided.\",\"type\":\"invalid_request_error\"}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_extrude_add_two_sided",
|
||||||
|
"message": "Provider rejected structured request (400): {\"error\":{\"message\":\"One of \\\"input\\\" or \\\"previous_response_id\\\" or 'prompt' or 'conversation' must be provided.\",\"type\":\"invalid_request_error\"}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_extrude_cut_blind",
|
||||||
|
"message": "Provider rejected structured request (400): {\"error\":{\"message\":\"One of \\\"input\\\" or \\\"previous_response_id\\\" or 'prompt' or 'conversation' must be provided.\",\"type\":\"invalid_request_error\"}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_fillet",
|
||||||
|
"message": "Provider rejected structured request (400): {\"error\":{\"message\":\"One of \\\"input\\\" or \\\"previous_response_id\\\" or 'prompt' or 'conversation' must be provided.\",\"type\":\"invalid_request_error\"}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_hole_blind",
|
||||||
|
"message": "Provider rejected structured request (400): {\"error\":{\"message\":\"One of \\\"input\\\" or \\\"previous_response_id\\\" or 'prompt' or 'conversation' must be provided.\",\"type\":\"invalid_request_error\"}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_hole_counterbore",
|
||||||
|
"message": "Provider rejected structured request (400): {\"error\":{\"message\":\"One of \\\"input\\\" or \\\"previous_response_id\\\" or 'prompt' or 'conversation' must be provided.\",\"type\":\"invalid_request_error\"}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_hole_countersink",
|
||||||
|
"message": "Provider rejected structured request (400): {\"error\":{\"message\":\"One of \\\"input\\\" or \\\"previous_response_id\\\" or 'prompt' or 'conversation' must be provided.\",\"type\":\"invalid_request_error\"}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_hole_wizard",
|
||||||
|
"message": "Provider rejected structured request (400): {\"error\":{\"message\":\"One of \\\"input\\\" or \\\"previous_response_id\\\" or 'prompt' or 'conversation' must be provided.\",\"type\":\"invalid_request_error\"}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_pattern_linear",
|
||||||
|
"message": "Provider rejected structured request (400): {\"error\":{\"message\":\"One of \\\"input\\\" or \\\"previous_response_id\\\" or 'prompt' or 'conversation' must be provided.\",\"type\":\"invalid_request_error\"}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_pattern_mirror",
|
||||||
|
"message": "Provider rejected structured request (400): {\"error\":{\"message\":\"One of \\\"input\\\" or \\\"previous_response_id\\\" or 'prompt' or 'conversation' must be provided.\",\"type\":\"invalid_request_error\"}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_reference_axis",
|
||||||
|
"message": "Provider rejected structured request (400): {\"error\":{\"message\":\"One of \\\"input\\\" or \\\"previous_response_id\\\" or 'prompt' or 'conversation' must be provided.\",\"type\":\"invalid_request_error\"}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_reference_plane",
|
||||||
|
"message": "Provider rejected structured request (400): {\"error\":{\"message\":\"One of \\\"input\\\" or \\\"previous_response_id\\\" or 'prompt' or 'conversation' must be provided.\",\"type\":\"invalid_request_error\"}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_revolve_add",
|
||||||
|
"message": "Provider rejected structured request (400): {\"error\":{\"message\":\"One of \\\"input\\\" or \\\"previous_response_id\\\" or 'prompt' or 'conversation' must be provided.\",\"type\":\"invalid_request_error\"}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_revolve_cut",
|
||||||
|
"message": "Provider rejected structured request (400): {\"error\":{\"message\":\"One of \\\"input\\\" or \\\"previous_response_id\\\" or 'prompt' or 'conversation' must be provided.\",\"type\":\"invalid_request_error\"}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_sphere_add",
|
||||||
|
"message": "Provider rejected structured request (400): {\"error\":{\"message\":\"One of \\\"input\\\" or \\\"previous_response_id\\\" or 'prompt' or 'conversation' must be provided.\",\"type\":\"invalid_request_error\"}}"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"usage": {
|
||||||
|
"prompt_tokens": 0,
|
||||||
|
"completion_tokens": 0
|
||||||
|
},
|
||||||
|
"mode": "openai_compatible_tool_calling"
|
||||||
|
},
|
||||||
|
"reviewer_capability": {
|
||||||
|
"schema_hash": "08f70b6c1f06c0c12b3e5122a7c2d1d4077c07ceb397a8ffad05089714bd2fdf",
|
||||||
|
"tool_count": 21,
|
||||||
|
"supported": false,
|
||||||
|
"failures": [
|
||||||
|
{
|
||||||
|
"tool": "submit_requirements_draft_batch",
|
||||||
|
"message": "Provider rejected structured request (400): {\"error\":{\"message\":\"One of \\\"input\\\" or \\\"previous_response_id\\\" or 'prompt' or 'conversation' must be provided.\",\"type\":\"invalid_request_error\"}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "review_requirements",
|
||||||
|
"message": "Provider rejected structured request (400): {\"error\":{\"message\":\"One of \\\"input\\\" or \\\"previous_response_id\\\" or 'prompt' or 'conversation' must be provided.\",\"type\":\"invalid_request_error\"}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "propose_next_action",
|
||||||
|
"message": "Provider rejected structured request (400): {\"error\":{\"message\":\"One of \\\"input\\\" or \\\"previous_response_id\\\" or 'prompt' or 'conversation' must be provided.\",\"type\":\"invalid_request_error\"}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "review_candidate",
|
||||||
|
"message": "Provider rejected structured request (400): {\"error\":{\"message\":\"One of \\\"input\\\" or \\\"previous_response_id\\\" or 'prompt' or 'conversation' must be provided.\",\"type\":\"invalid_request_error\"}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "review_final",
|
||||||
|
"message": "Provider rejected structured request (400): {\"error\":{\"message\":\"One of \\\"input\\\" or \\\"previous_response_id\\\" or 'prompt' or 'conversation' must be provided.\",\"type\":\"invalid_request_error\"}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_chamfer",
|
||||||
|
"message": "Provider rejected structured request (400): {\"error\":{\"message\":\"One of \\\"input\\\" or \\\"previous_response_id\\\" or 'prompt' or 'conversation' must be provided.\",\"type\":\"invalid_request_error\"}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_extrude_add_blind",
|
||||||
|
"message": "Provider rejected structured request (400): {\"error\":{\"message\":\"One of \\\"input\\\" or \\\"previous_response_id\\\" or 'prompt' or 'conversation' must be provided.\",\"type\":\"invalid_request_error\"}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_extrude_add_two_sided",
|
||||||
|
"message": "Provider rejected structured request (400): {\"error\":{\"message\":\"One of \\\"input\\\" or \\\"previous_response_id\\\" or 'prompt' or 'conversation' must be provided.\",\"type\":\"invalid_request_error\"}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_extrude_cut_blind",
|
||||||
|
"message": "Provider rejected structured request (400): {\"error\":{\"message\":\"One of \\\"input\\\" or \\\"previous_response_id\\\" or 'prompt' or 'conversation' must be provided.\",\"type\":\"invalid_request_error\"}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_fillet",
|
||||||
|
"message": "Provider rejected structured request (400): {\"error\":{\"message\":\"One of \\\"input\\\" or \\\"previous_response_id\\\" or 'prompt' or 'conversation' must be provided.\",\"type\":\"invalid_request_error\"}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_hole_blind",
|
||||||
|
"message": "Provider rejected structured request (400): {\"error\":{\"message\":\"One of \\\"input\\\" or \\\"previous_response_id\\\" or 'prompt' or 'conversation' must be provided.\",\"type\":\"invalid_request_error\"}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_hole_counterbore",
|
||||||
|
"message": "Provider rejected structured request (400): {\"error\":{\"message\":\"One of \\\"input\\\" or \\\"previous_response_id\\\" or 'prompt' or 'conversation' must be provided.\",\"type\":\"invalid_request_error\"}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_hole_countersink",
|
||||||
|
"message": "Provider rejected structured request (400): {\"error\":{\"message\":\"One of \\\"input\\\" or \\\"previous_response_id\\\" or 'prompt' or 'conversation' must be provided.\",\"type\":\"invalid_request_error\"}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_hole_wizard",
|
||||||
|
"message": "Provider rejected structured request (400): {\"error\":{\"message\":\"One of \\\"input\\\" or \\\"previous_response_id\\\" or 'prompt' or 'conversation' must be provided.\",\"type\":\"invalid_request_error\"}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_pattern_linear",
|
||||||
|
"message": "Provider rejected structured request (400): {\"error\":{\"message\":\"One of \\\"input\\\" or \\\"previous_response_id\\\" or 'prompt' or 'conversation' must be provided.\",\"type\":\"invalid_request_error\"}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_pattern_mirror",
|
||||||
|
"message": "Provider rejected structured request (400): {\"error\":{\"message\":\"One of \\\"input\\\" or \\\"previous_response_id\\\" or 'prompt' or 'conversation' must be provided.\",\"type\":\"invalid_request_error\"}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_reference_axis",
|
||||||
|
"message": "Provider rejected structured request (400): {\"error\":{\"message\":\"One of \\\"input\\\" or \\\"previous_response_id\\\" or 'prompt' or 'conversation' must be provided.\",\"type\":\"invalid_request_error\"}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_reference_plane",
|
||||||
|
"message": "Provider rejected structured request (400): {\"error\":{\"message\":\"One of \\\"input\\\" or \\\"previous_response_id\\\" or 'prompt' or 'conversation' must be provided.\",\"type\":\"invalid_request_error\"}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_revolve_add",
|
||||||
|
"message": "Provider rejected structured request (400): {\"error\":{\"message\":\"One of \\\"input\\\" or \\\"previous_response_id\\\" or 'prompt' or 'conversation' must be provided.\",\"type\":\"invalid_request_error\"}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_revolve_cut",
|
||||||
|
"message": "Provider rejected structured request (400): {\"error\":{\"message\":\"One of \\\"input\\\" or \\\"previous_response_id\\\" or 'prompt' or 'conversation' must be provided.\",\"type\":\"invalid_request_error\"}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_sphere_add",
|
||||||
|
"message": "Provider rejected structured request (400): {\"error\":{\"message\":\"One of \\\"input\\\" or \\\"previous_response_id\\\" or 'prompt' or 'conversation' must be provided.\",\"type\":\"invalid_request_error\"}}"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"usage": {
|
||||||
|
"prompt_tokens": 0,
|
||||||
|
"completion_tokens": 0
|
||||||
|
},
|
||||||
|
"mode": "openai_compatible_tool_calling"
|
||||||
|
},
|
||||||
|
"suite": "release",
|
||||||
|
"require_live": true,
|
||||||
|
"report_root": "/Users/lk/Projects/cdsl-cad/backend/live-evals/20260831T084033Z"
|
||||||
|
}
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
{
|
||||||
|
"status": "LIVE_EVAL_BLOCKED",
|
||||||
|
"error": "MODEL_STRUCTURED_OUTPUT_UNSUPPORTED",
|
||||||
|
"author_capability": {
|
||||||
|
"schema_hash": "08f70b6c1f06c0c12b3e5122a7c2d1d4077c07ceb397a8ffad05089714bd2fdf",
|
||||||
|
"tool_count": 21,
|
||||||
|
"supported": false,
|
||||||
|
"failures": [
|
||||||
|
{
|
||||||
|
"tool": "submit_requirements_draft_batch",
|
||||||
|
"message": "Provider response has no completion choices"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "review_requirements",
|
||||||
|
"message": "Provider response has no completion choices"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "propose_next_action",
|
||||||
|
"message": "Provider response has no completion choices"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "review_candidate",
|
||||||
|
"message": "Provider response has no completion choices"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "review_final",
|
||||||
|
"message": "Provider response has no completion choices"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_chamfer",
|
||||||
|
"message": "Provider response has no completion choices"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_extrude_add_blind",
|
||||||
|
"message": "Provider response has no completion choices"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_extrude_add_two_sided",
|
||||||
|
"message": "Provider response has no completion choices"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_extrude_cut_blind",
|
||||||
|
"message": "Provider response has no completion choices"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_fillet",
|
||||||
|
"message": "Provider response has no completion choices"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_hole_blind",
|
||||||
|
"message": "Provider response has no completion choices"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_hole_counterbore",
|
||||||
|
"message": "Provider response has no completion choices"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_hole_countersink",
|
||||||
|
"message": "Provider response has no completion choices"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_hole_wizard",
|
||||||
|
"message": "Provider response has no completion choices"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_pattern_linear",
|
||||||
|
"message": "Provider response has no completion choices"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_pattern_mirror",
|
||||||
|
"message": "Provider response has no completion choices"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_reference_axis",
|
||||||
|
"message": "Provider response has no completion choices"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_reference_plane",
|
||||||
|
"message": "Provider response has no completion choices"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_revolve_add",
|
||||||
|
"message": "Provider response has no completion choices"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_revolve_cut",
|
||||||
|
"message": "Provider response has no completion choices"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_sphere_add",
|
||||||
|
"message": "Provider response has no completion choices"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"usage": {
|
||||||
|
"prompt_tokens": 0,
|
||||||
|
"completion_tokens": 0
|
||||||
|
},
|
||||||
|
"mode": "openai_compatible_tool_calling"
|
||||||
|
},
|
||||||
|
"reviewer_capability": {
|
||||||
|
"schema_hash": "08f70b6c1f06c0c12b3e5122a7c2d1d4077c07ceb397a8ffad05089714bd2fdf",
|
||||||
|
"tool_count": 21,
|
||||||
|
"supported": false,
|
||||||
|
"failures": [
|
||||||
|
{
|
||||||
|
"tool": "submit_requirements_draft_batch",
|
||||||
|
"message": "Provider response has no completion choices"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "review_requirements",
|
||||||
|
"message": "Provider response has no completion choices"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "propose_next_action",
|
||||||
|
"message": "Provider response has no completion choices"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "review_candidate",
|
||||||
|
"message": "Provider response has no completion choices"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "review_final",
|
||||||
|
"message": "Provider response has no completion choices"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_chamfer",
|
||||||
|
"message": "Provider response has no completion choices"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_extrude_add_blind",
|
||||||
|
"message": "Provider response has no completion choices"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_extrude_add_two_sided",
|
||||||
|
"message": "Provider response has no completion choices"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_extrude_cut_blind",
|
||||||
|
"message": "Provider response has no completion choices"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_fillet",
|
||||||
|
"message": "Provider response has no completion choices"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_hole_blind",
|
||||||
|
"message": "Provider response has no completion choices"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_hole_counterbore",
|
||||||
|
"message": "Provider response has no completion choices"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_hole_countersink",
|
||||||
|
"message": "Provider response has no completion choices"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_hole_wizard",
|
||||||
|
"message": "Provider response has no completion choices"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_pattern_linear",
|
||||||
|
"message": "Provider response has no completion choices"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_pattern_mirror",
|
||||||
|
"message": "Provider response has no completion choices"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_reference_axis",
|
||||||
|
"message": "Provider response has no completion choices"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_reference_plane",
|
||||||
|
"message": "Provider response has no completion choices"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_revolve_add",
|
||||||
|
"message": "Provider response has no completion choices"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_revolve_cut",
|
||||||
|
"message": "Provider response has no completion choices"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_sphere_add",
|
||||||
|
"message": "Provider response has no completion choices"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"usage": {
|
||||||
|
"prompt_tokens": 0,
|
||||||
|
"completion_tokens": 0
|
||||||
|
},
|
||||||
|
"mode": "openai_compatible_tool_calling"
|
||||||
|
},
|
||||||
|
"suite": "smoke",
|
||||||
|
"require_live": true,
|
||||||
|
"report_root": "/Users/lk/Projects/cdsl-cad/backend/live-evals/20260831T084711Z"
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
{
|
||||||
|
"status": "LIVE_EVAL_BLOCKED",
|
||||||
|
"error": "MODEL_STRUCTURED_OUTPUT_UNSUPPORTED",
|
||||||
|
"author_capability": {
|
||||||
|
"schema_hash": "08f70b6c1f06c0c12b3e5122a7c2d1d4077c07ceb397a8ffad05089714bd2fdf",
|
||||||
|
"tool_count": 21,
|
||||||
|
"supported": false,
|
||||||
|
"failures": [
|
||||||
|
{
|
||||||
|
"tool": "conformance_hole_counterbore",
|
||||||
|
"message": "Provider arguments violate schema: 'selector_tokens' is a required property"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_hole_countersink",
|
||||||
|
"message": "Provider arguments violate schema: 'selector_tokens' is a required property"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_hole_wizard",
|
||||||
|
"message": "Provider arguments violate schema: 'selector_tokens' is a required property"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"usage": {
|
||||||
|
"prompt_tokens": 97709,
|
||||||
|
"completion_tokens": 3243
|
||||||
|
},
|
||||||
|
"mode": "openai_compatible_tool_calling"
|
||||||
|
},
|
||||||
|
"reviewer_capability": {
|
||||||
|
"schema_hash": "08f70b6c1f06c0c12b3e5122a7c2d1d4077c07ceb397a8ffad05089714bd2fdf",
|
||||||
|
"tool_count": 21,
|
||||||
|
"supported": true,
|
||||||
|
"failures": [],
|
||||||
|
"usage": {
|
||||||
|
"prompt_tokens": 97709,
|
||||||
|
"completion_tokens": 1390
|
||||||
|
},
|
||||||
|
"mode": "openai_compatible_tool_calling"
|
||||||
|
},
|
||||||
|
"suite": "smoke",
|
||||||
|
"require_live": true,
|
||||||
|
"report_root": "/Users/lk/Projects/cdsl-cad/backend/live-evals/20260831T085154Z"
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
{
|
||||||
|
"status": "LIVE_EVAL_BLOCKED",
|
||||||
|
"error": "MODEL_STRUCTURED_OUTPUT_UNSUPPORTED",
|
||||||
|
"author_capability": {
|
||||||
|
"schema_hash": "08f70b6c1f06c0c12b3e5122a7c2d1d4077c07ceb397a8ffad05089714bd2fdf",
|
||||||
|
"tool_count": 21,
|
||||||
|
"supported": false,
|
||||||
|
"failures": [
|
||||||
|
{
|
||||||
|
"tool": "submit_requirements_draft_batch",
|
||||||
|
"message": "Provider rejected structured request (400): {\"error\":{\"code\":\"invalid_function_parameters\",\"message\":\"Invalid schema for function 'submit_requirements_draft_batch': In context=('properties', 'expected'), 'minProperties' is not permitted.\",\"param\":\"tools[0].parameters\",\"type\":\"invalid_request_error\"}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "review_requirements",
|
||||||
|
"message": "Provider rejected structured request (400): {\"error\":{\"code\":\"invalid_function_parameters\",\"message\":\"Invalid schema for function 'review_requirements': In context=(), 'required' is required to be supplied and to be an array including every key in properties. Missing 'question'.\",\"param\":\"tools[0].parameters\",\"type\":\"invalid_request_error\"}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "review_candidate",
|
||||||
|
"message": "Provider rejected structured request (400): {\"error\":{\"code\":\"invalid_function_parameters\",\"message\":\"Invalid schema for function 'review_candidate': In context=(), 'required' is required to be supplied and to be an array including every key in properties. Missing 'evidence_refs'.\",\"param\":\"tools[0].parameters\",\"type\":\"invalid_request_error\"}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "review_final",
|
||||||
|
"message": "Provider rejected structured request (400): {\"error\":{\"code\":\"invalid_function_parameters\",\"message\":\"Invalid schema for function 'review_final': In context=(), 'required' is required to be supplied and to be an array including every key in properties. Missing 'evidence_refs'.\",\"param\":\"tools[0].parameters\",\"type\":\"invalid_request_error\"}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_chamfer",
|
||||||
|
"message": "Provider rejected structured request (400): {\"error\":{\"code\":\"invalid_function_parameters\",\"message\":\"Invalid schema for function 'conformance_chamfer': In context=('properties', 'feature', 'properties', 'atomic_id'), schema must have a 'type' key.\",\"param\":\"tools[0].parameters\",\"type\":\"invalid_request_error\"}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_extrude_add_blind",
|
||||||
|
"message": "Provider rejected structured request (400): {\"error\":{\"code\":\"invalid_function_parameters\",\"message\":\"Invalid schema for function 'conformance_extrude_add_blind': In context=('properties', 'feature', 'properties', 'atomic_id'), schema must have a 'type' key.\",\"param\":\"tools[0].parameters\",\"type\":\"invalid_request_error\"}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_extrude_add_two_sided",
|
||||||
|
"message": "Provider rejected structured request (400): {\"error\":{\"code\":\"invalid_function_parameters\",\"message\":\"Invalid schema for function 'conformance_extrude_add_two_sided': In context=('properties', 'feature', 'properties', 'atomic_id'), schema must have a 'type' key.\",\"param\":\"tools[0].parameters\",\"type\":\"invalid_request_error\"}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_extrude_cut_blind",
|
||||||
|
"message": "Provider rejected structured request (400): {\"error\":{\"code\":\"invalid_function_parameters\",\"message\":\"Invalid schema for function 'conformance_extrude_cut_blind': In context=('properties', 'feature', 'properties', 'atomic_id'), schema must have a 'type' key.\",\"param\":\"tools[0].parameters\",\"type\":\"invalid_request_error\"}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_fillet",
|
||||||
|
"message": "Provider rejected structured request (400): {\"error\":{\"code\":\"invalid_function_parameters\",\"message\":\"Invalid schema for function 'conformance_fillet': In context=('properties', 'feature', 'properties', 'atomic_id'), schema must have a 'type' key.\",\"param\":\"tools[0].parameters\",\"type\":\"invalid_request_error\"}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_hole_blind",
|
||||||
|
"message": "Provider rejected structured request (400): {\"error\":{\"code\":\"invalid_function_parameters\",\"message\":\"Invalid schema for function 'conformance_hole_blind': In context=('properties', 'feature', 'properties', 'atomic_id'), schema must have a 'type' key.\",\"param\":\"tools[0].parameters\",\"type\":\"invalid_request_error\"}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_hole_counterbore",
|
||||||
|
"message": "Provider rejected structured request (400): {\"error\":{\"code\":\"invalid_function_parameters\",\"message\":\"Invalid schema for function 'conformance_hole_counterbore': In context=('properties', 'feature', 'properties', 'atomic_id'), schema must have a 'type' key.\",\"param\":\"tools[0].parameters\",\"type\":\"invalid_request_error\"}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_hole_countersink",
|
||||||
|
"message": "Provider rejected structured request (400): {\"error\":{\"code\":\"invalid_function_parameters\",\"message\":\"Invalid schema for function 'conformance_hole_countersink': In context=('properties', 'feature', 'properties', 'atomic_id'), schema must have a 'type' key.\",\"param\":\"tools[0].parameters\",\"type\":\"invalid_request_error\"}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_hole_wizard",
|
||||||
|
"message": "Provider rejected structured request (400): {\"error\":{\"code\":\"invalid_function_parameters\",\"message\":\"Invalid schema for function 'conformance_hole_wizard': In context=('properties', 'feature', 'properties', 'atomic_id'), schema must have a 'type' key.\",\"param\":\"tools[0].parameters\",\"type\":\"invalid_request_error\"}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_pattern_linear",
|
||||||
|
"message": "Provider rejected structured request (400): {\"error\":{\"code\":\"invalid_function_parameters\",\"message\":\"Invalid schema for function 'conformance_pattern_linear': In context=('properties', 'feature', 'properties', 'atomic_id'), schema must have a 'type' key.\",\"param\":\"tools[0].parameters\",\"type\":\"invalid_request_error\"}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_pattern_mirror",
|
||||||
|
"message": "Provider rejected structured request (400): {\"error\":{\"code\":\"invalid_function_parameters\",\"message\":\"Invalid schema for function 'conformance_pattern_mirror': In context=('properties', 'feature', 'properties', 'atomic_id'), schema must have a 'type' key.\",\"param\":\"tools[0].parameters\",\"type\":\"invalid_request_error\"}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_reference_axis",
|
||||||
|
"message": "Provider rejected structured request (400): {\"error\":{\"code\":\"invalid_function_parameters\",\"message\":\"Invalid schema for function 'conformance_reference_axis': In context=('properties', 'feature', 'properties', 'atomic_id'), schema must have a 'type' key.\",\"param\":\"tools[0].parameters\",\"type\":\"invalid_request_error\"}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_reference_plane",
|
||||||
|
"message": "Provider rejected structured request (400): {\"error\":{\"code\":\"invalid_function_parameters\",\"message\":\"Invalid schema for function 'conformance_reference_plane': In context=('properties', 'feature', 'properties', 'atomic_id'), schema must have a 'type' key.\",\"param\":\"tools[0].parameters\",\"type\":\"invalid_request_error\"}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_revolve_add",
|
||||||
|
"message": "Provider rejected structured request (400): {\"error\":{\"code\":\"invalid_function_parameters\",\"message\":\"Invalid schema for function 'conformance_revolve_add': In context=('properties', 'feature', 'properties', 'atomic_id'), schema must have a 'type' key.\",\"param\":\"tools[0].parameters\",\"type\":\"invalid_request_error\"}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_revolve_cut",
|
||||||
|
"message": "Provider rejected structured request (400): {\"error\":{\"code\":\"invalid_function_parameters\",\"message\":\"Invalid schema for function 'conformance_revolve_cut': In context=('properties', 'feature', 'properties', 'atomic_id'), schema must have a 'type' key.\",\"param\":\"tools[0].parameters\",\"type\":\"invalid_request_error\"}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_sphere_add",
|
||||||
|
"message": "Provider rejected structured request (400): {\"error\":{\"code\":\"invalid_function_parameters\",\"message\":\"Invalid schema for function 'conformance_sphere_add': In context=('properties', 'feature', 'properties', 'atomic_id'), schema must have a 'type' key.\",\"param\":\"tools[0].parameters\",\"type\":\"invalid_request_error\"}}"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"usage": {
|
||||||
|
"prompt_tokens": 4577,
|
||||||
|
"completion_tokens": 109
|
||||||
|
},
|
||||||
|
"mode": "openai_compatible_tool_calling"
|
||||||
|
},
|
||||||
|
"reviewer_capability": {
|
||||||
|
"schema_hash": "08f70b6c1f06c0c12b3e5122a7c2d1d4077c07ceb397a8ffad05089714bd2fdf",
|
||||||
|
"tool_count": 21,
|
||||||
|
"supported": false,
|
||||||
|
"failures": [
|
||||||
|
{
|
||||||
|
"tool": "submit_requirements_draft_batch",
|
||||||
|
"message": "Provider rejected structured request (400): {\"error\":{\"code\":\"invalid_function_parameters\",\"message\":\"Invalid schema for function 'submit_requirements_draft_batch': In context=('properties', 'expected'), 'minProperties' is not permitted.\",\"param\":\"tools[0].parameters\",\"type\":\"invalid_request_error\"}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "review_requirements",
|
||||||
|
"message": "Provider rejected structured request (400): {\"error\":{\"code\":\"invalid_function_parameters\",\"message\":\"Invalid schema for function 'review_requirements': In context=(), 'required' is required to be supplied and to be an array including every key in properties. Missing 'question'.\",\"param\":\"tools[0].parameters\",\"type\":\"invalid_request_error\"}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "review_candidate",
|
||||||
|
"message": "Provider rejected structured request (400): {\"error\":{\"code\":\"invalid_function_parameters\",\"message\":\"Invalid schema for function 'review_candidate': In context=(), 'required' is required to be supplied and to be an array including every key in properties. Missing 'evidence_refs'.\",\"param\":\"tools[0].parameters\",\"type\":\"invalid_request_error\"}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "review_final",
|
||||||
|
"message": "Provider rejected structured request (400): {\"error\":{\"code\":\"invalid_function_parameters\",\"message\":\"Invalid schema for function 'review_final': In context=(), 'required' is required to be supplied and to be an array including every key in properties. Missing 'evidence_refs'.\",\"param\":\"tools[0].parameters\",\"type\":\"invalid_request_error\"}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_chamfer",
|
||||||
|
"message": "Provider rejected structured request (400): {\"error\":{\"code\":\"invalid_function_parameters\",\"message\":\"Invalid schema for function 'conformance_chamfer': In context=('properties', 'feature', 'properties', 'atomic_id'), schema must have a 'type' key.\",\"param\":\"tools[0].parameters\",\"type\":\"invalid_request_error\"}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_extrude_add_blind",
|
||||||
|
"message": "Provider rejected structured request (400): {\"error\":{\"code\":\"invalid_function_parameters\",\"message\":\"Invalid schema for function 'conformance_extrude_add_blind': In context=('properties', 'feature', 'properties', 'atomic_id'), schema must have a 'type' key.\",\"param\":\"tools[0].parameters\",\"type\":\"invalid_request_error\"}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_extrude_add_two_sided",
|
||||||
|
"message": "Provider rejected structured request (400): {\"error\":{\"code\":\"invalid_function_parameters\",\"message\":\"Invalid schema for function 'conformance_extrude_add_two_sided': In context=('properties', 'feature', 'properties', 'atomic_id'), schema must have a 'type' key.\",\"param\":\"tools[0].parameters\",\"type\":\"invalid_request_error\"}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_extrude_cut_blind",
|
||||||
|
"message": "Provider rejected structured request (400): {\"error\":{\"code\":\"invalid_function_parameters\",\"message\":\"Invalid schema for function 'conformance_extrude_cut_blind': In context=('properties', 'feature', 'properties', 'atomic_id'), schema must have a 'type' key.\",\"param\":\"tools[0].parameters\",\"type\":\"invalid_request_error\"}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_fillet",
|
||||||
|
"message": "Provider rejected structured request (400): {\"error\":{\"code\":\"invalid_function_parameters\",\"message\":\"Invalid schema for function 'conformance_fillet': In context=('properties', 'feature', 'properties', 'atomic_id'), schema must have a 'type' key.\",\"param\":\"tools[0].parameters\",\"type\":\"invalid_request_error\"}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_hole_blind",
|
||||||
|
"message": "Provider rejected structured request (400): {\"error\":{\"code\":\"invalid_function_parameters\",\"message\":\"Invalid schema for function 'conformance_hole_blind': In context=('properties', 'feature', 'properties', 'atomic_id'), schema must have a 'type' key.\",\"param\":\"tools[0].parameters\",\"type\":\"invalid_request_error\"}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_hole_counterbore",
|
||||||
|
"message": "Provider rejected structured request (400): {\"error\":{\"code\":\"invalid_function_parameters\",\"message\":\"Invalid schema for function 'conformance_hole_counterbore': In context=('properties', 'feature', 'properties', 'atomic_id'), schema must have a 'type' key.\",\"param\":\"tools[0].parameters\",\"type\":\"invalid_request_error\"}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_hole_countersink",
|
||||||
|
"message": "Provider rejected structured request (400): {\"error\":{\"code\":\"invalid_function_parameters\",\"message\":\"Invalid schema for function 'conformance_hole_countersink': In context=('properties', 'feature', 'properties', 'atomic_id'), schema must have a 'type' key.\",\"param\":\"tools[0].parameters\",\"type\":\"invalid_request_error\"}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_hole_wizard",
|
||||||
|
"message": "Provider rejected structured request (400): {\"error\":{\"code\":\"invalid_function_parameters\",\"message\":\"Invalid schema for function 'conformance_hole_wizard': In context=('properties', 'feature', 'properties', 'atomic_id'), schema must have a 'type' key.\",\"param\":\"tools[0].parameters\",\"type\":\"invalid_request_error\"}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_pattern_linear",
|
||||||
|
"message": "Provider rejected structured request (400): {\"error\":{\"code\":\"invalid_function_parameters\",\"message\":\"Invalid schema for function 'conformance_pattern_linear': In context=('properties', 'feature', 'properties', 'atomic_id'), schema must have a 'type' key.\",\"param\":\"tools[0].parameters\",\"type\":\"invalid_request_error\"}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_pattern_mirror",
|
||||||
|
"message": "Provider rejected structured request (400): {\"error\":{\"code\":\"invalid_function_parameters\",\"message\":\"Invalid schema for function 'conformance_pattern_mirror': In context=('properties', 'feature', 'properties', 'atomic_id'), schema must have a 'type' key.\",\"param\":\"tools[0].parameters\",\"type\":\"invalid_request_error\"}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_reference_axis",
|
||||||
|
"message": "Provider rejected structured request (400): {\"error\":{\"code\":\"invalid_function_parameters\",\"message\":\"Invalid schema for function 'conformance_reference_axis': In context=('properties', 'feature', 'properties', 'atomic_id'), schema must have a 'type' key.\",\"param\":\"tools[0].parameters\",\"type\":\"invalid_request_error\"}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_reference_plane",
|
||||||
|
"message": "Provider rejected structured request (400): {\"error\":{\"code\":\"invalid_function_parameters\",\"message\":\"Invalid schema for function 'conformance_reference_plane': In context=('properties', 'feature', 'properties', 'atomic_id'), schema must have a 'type' key.\",\"param\":\"tools[0].parameters\",\"type\":\"invalid_request_error\"}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_revolve_add",
|
||||||
|
"message": "Provider rejected structured request (400): {\"error\":{\"code\":\"invalid_function_parameters\",\"message\":\"Invalid schema for function 'conformance_revolve_add': In context=('properties', 'feature', 'properties', 'atomic_id'), schema must have a 'type' key.\",\"param\":\"tools[0].parameters\",\"type\":\"invalid_request_error\"}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_revolve_cut",
|
||||||
|
"message": "Provider rejected structured request (400): {\"error\":{\"code\":\"invalid_function_parameters\",\"message\":\"Invalid schema for function 'conformance_revolve_cut': In context=('properties', 'feature', 'properties', 'atomic_id'), schema must have a 'type' key.\",\"param\":\"tools[0].parameters\",\"type\":\"invalid_request_error\"}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool": "conformance_sphere_add",
|
||||||
|
"message": "Provider rejected structured request (400): {\"error\":{\"code\":\"invalid_function_parameters\",\"message\":\"Invalid schema for function 'conformance_sphere_add': In context=('properties', 'feature', 'properties', 'atomic_id'), schema must have a 'type' key.\",\"param\":\"tools[0].parameters\",\"type\":\"invalid_request_error\"}}"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"usage": {
|
||||||
|
"prompt_tokens": 4577,
|
||||||
|
"completion_tokens": 53
|
||||||
|
},
|
||||||
|
"mode": "openai_compatible_tool_calling"
|
||||||
|
},
|
||||||
|
"suite": "smoke",
|
||||||
|
"require_live": true,
|
||||||
|
"report_root": "/Users/lk/Projects/cdsl-cad/backend/live-evals/20260831T085626Z"
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
{
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"acceptance_claims": [
|
||||||
|
{
|
||||||
|
"claim_kind": "solid_count_equals",
|
||||||
|
"expected": {
|
||||||
|
"value": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"claim_kind": "single_connected_body",
|
||||||
|
"expected": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"claim_kind": "bbox_dimension_mm",
|
||||||
|
"expected": {
|
||||||
|
"axis": "x",
|
||||||
|
"tolerance_mm": 0.5,
|
||||||
|
"value": 120
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"claim_kind": "bbox_dimension_mm",
|
||||||
|
"expected": {
|
||||||
|
"axis": "y",
|
||||||
|
"tolerance_mm": 0.5,
|
||||||
|
"value": 120
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"claim_kind": "bbox_dimension_mm",
|
||||||
|
"expected": {
|
||||||
|
"axis": "z",
|
||||||
|
"tolerance_mm": 0.5,
|
||||||
|
"value": 12
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"claim_kind": "through_cylindrical_bore",
|
||||||
|
"expected": {
|
||||||
|
"count": 1,
|
||||||
|
"diameter_mm": 40,
|
||||||
|
"tolerance_mm": 0.5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"assumptions": [],
|
||||||
|
"draft_id": "draft_001",
|
||||||
|
"source_ids": [
|
||||||
|
"src_001"
|
||||||
|
],
|
||||||
|
"statement": "Create one connected round flange with a 120 mm outer diameter, 12 mm thickness, and a centered 40 mm through bore."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"revision": 1,
|
||||||
|
"schema_version": "cad.requirements-draft.v1"
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
{
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"acceptance_claims": [
|
||||||
|
{
|
||||||
|
"claim_kind": "solid_count_equals",
|
||||||
|
"expected": {
|
||||||
|
"value": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"claim_kind": "single_connected_body",
|
||||||
|
"expected": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"claim_kind": "bbox_dimension_mm",
|
||||||
|
"expected": {
|
||||||
|
"axis": "x",
|
||||||
|
"tolerance_mm": 0.5,
|
||||||
|
"value": 120
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"claim_kind": "bbox_dimension_mm",
|
||||||
|
"expected": {
|
||||||
|
"axis": "y",
|
||||||
|
"tolerance_mm": 0.5,
|
||||||
|
"value": 120
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"claim_kind": "bbox_dimension_mm",
|
||||||
|
"expected": {
|
||||||
|
"axis": "z",
|
||||||
|
"tolerance_mm": 0.5,
|
||||||
|
"value": 12
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"claim_kind": "through_cylindrical_bore",
|
||||||
|
"expected": {
|
||||||
|
"count": 1,
|
||||||
|
"diameter_mm": 40,
|
||||||
|
"tolerance_mm": 0.5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"assumptions": [],
|
||||||
|
"draft_id": "draft_001",
|
||||||
|
"source_ids": [
|
||||||
|
"src_001"
|
||||||
|
],
|
||||||
|
"statement": "Create one connected round flange with a 120 mm outer diameter, 12 mm thickness, and a centered 40 mm through bore."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"revision": 2,
|
||||||
|
"schema_version": "cad.requirements-draft.v1"
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"schema_version": "cad.source-index.v1",
|
||||||
|
"sources": {
|
||||||
|
"src_001": "Create one connected round flange in millimetres: outer diameter 120 mm, thickness 12 mm, and a centered 40 mm through bore. Verify the single solid, thickness, and through bore topology."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Create one connected round flange in millimetres: outer diameter 120 mm, thickness 12 mm, and a centered 40 mm through bore. Verify the single solid, thickness, and through bore topology.
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
{
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"acceptance_claims": [
|
||||||
|
{
|
||||||
|
"claim_kind": "single_connected_body",
|
||||||
|
"expected": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"claim_kind": "solid_count_equals",
|
||||||
|
"expected": {
|
||||||
|
"value": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"claim_kind": "bbox_dimension_mm",
|
||||||
|
"expected": {
|
||||||
|
"axis": "x",
|
||||||
|
"tolerance_mm": 0,
|
||||||
|
"value": 80
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"claim_kind": "bbox_dimension_mm",
|
||||||
|
"expected": {
|
||||||
|
"axis": "y",
|
||||||
|
"tolerance_mm": 0,
|
||||||
|
"value": 50
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"claim_kind": "bbox_dimension_mm",
|
||||||
|
"expected": {
|
||||||
|
"axis": "z",
|
||||||
|
"tolerance_mm": 0,
|
||||||
|
"value": 8
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"assumptions": [],
|
||||||
|
"draft_id": "draft_001",
|
||||||
|
"source_ids": [
|
||||||
|
"src_001"
|
||||||
|
],
|
||||||
|
"statement": "Create one rectangular plate in millimetres with overall dimensions 80 mm by 50 mm by 8 mm."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"revision": 1,
|
||||||
|
"schema_version": "cad.requirements-draft.v1"
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
{
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"acceptance_claims": [
|
||||||
|
{
|
||||||
|
"claim_kind": "single_connected_body",
|
||||||
|
"expected": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"claim_kind": "solid_count_equals",
|
||||||
|
"expected": {
|
||||||
|
"value": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"claim_kind": "bbox_dimension_mm",
|
||||||
|
"expected": {
|
||||||
|
"axis": "x",
|
||||||
|
"tolerance_mm": 0,
|
||||||
|
"value": 80
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"claim_kind": "bbox_dimension_mm",
|
||||||
|
"expected": {
|
||||||
|
"axis": "y",
|
||||||
|
"tolerance_mm": 0,
|
||||||
|
"value": 50
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"claim_kind": "bbox_dimension_mm",
|
||||||
|
"expected": {
|
||||||
|
"axis": "z",
|
||||||
|
"tolerance_mm": 0,
|
||||||
|
"value": 8
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"assumptions": [],
|
||||||
|
"draft_id": "draft_001",
|
||||||
|
"source_ids": [
|
||||||
|
"src_001"
|
||||||
|
],
|
||||||
|
"statement": "Create one rectangular plate in millimetres with overall dimensions 80 mm by 50 mm by 8 mm."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"revision": 2,
|
||||||
|
"schema_version": "cad.requirements-draft.v1"
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
{
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"acceptance_claims": [
|
||||||
|
{
|
||||||
|
"claim_kind": "single_connected_body",
|
||||||
|
"expected": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"claim_kind": "solid_count_equals",
|
||||||
|
"expected": {
|
||||||
|
"value": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"claim_kind": "bbox_dimension_mm",
|
||||||
|
"expected": {
|
||||||
|
"axis": "x",
|
||||||
|
"tolerance_mm": 0,
|
||||||
|
"value": 80
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"claim_kind": "bbox_dimension_mm",
|
||||||
|
"expected": {
|
||||||
|
"axis": "y",
|
||||||
|
"tolerance_mm": 0,
|
||||||
|
"value": 50
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"claim_kind": "bbox_dimension_mm",
|
||||||
|
"expected": {
|
||||||
|
"axis": "z",
|
||||||
|
"tolerance_mm": 0,
|
||||||
|
"value": 8
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"assumptions": [],
|
||||||
|
"draft_id": "draft_001",
|
||||||
|
"source_ids": [
|
||||||
|
"src_001"
|
||||||
|
],
|
||||||
|
"statement": "Create one rectangular plate in millimetres with overall dimensions 80 mm by 50 mm by 8 mm."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"revision": 3,
|
||||||
|
"schema_version": "cad.requirements-draft.v1"
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"schema_version": "cad.source-index.v1",
|
||||||
|
"sources": {
|
||||||
|
"src_001": "Create one connected rectangular plate, 80 mm long, 50 mm wide, and 8 mm thick. Use millimetres. Verify the single solid and all three bounding-box dimensions."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Create one connected rectangular plate, 80 mm long, 50 mm wide, and 8 mm thick. Use millimetres. Verify the single solid and all three bounding-box dimensions.
|
||||||
@@ -0,0 +1,386 @@
|
|||||||
|
{
|
||||||
|
"status": "failed",
|
||||||
|
"author": {
|
||||||
|
"provider": "openai",
|
||||||
|
"model": "gpt-5.4-mini"
|
||||||
|
},
|
||||||
|
"reviewer": {
|
||||||
|
"provider": "openai",
|
||||||
|
"model": "gpt-5.5"
|
||||||
|
},
|
||||||
|
"author_capability": {
|
||||||
|
"schema_hash": "fb899a5de2f869b648df88a32d04a5eee92ae3d193ab23ee641892d6b59cd8e7",
|
||||||
|
"tool_count": 9,
|
||||||
|
"supported": true,
|
||||||
|
"failures": [],
|
||||||
|
"usage": {
|
||||||
|
"prompt_tokens": 41789,
|
||||||
|
"completion_tokens": 1247
|
||||||
|
},
|
||||||
|
"mode": "openai_compatible_tool_calling"
|
||||||
|
},
|
||||||
|
"reviewer_capability": {
|
||||||
|
"schema_hash": "fb899a5de2f869b648df88a32d04a5eee92ae3d193ab23ee641892d6b59cd8e7",
|
||||||
|
"tool_count": 9,
|
||||||
|
"supported": true,
|
||||||
|
"failures": [],
|
||||||
|
"usage": {
|
||||||
|
"prompt_tokens": 41789,
|
||||||
|
"completion_tokens": 538
|
||||||
|
},
|
||||||
|
"mode": "openai_compatible_tool_calling"
|
||||||
|
},
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"scenario": "rectangular_plate",
|
||||||
|
"repetition": 1,
|
||||||
|
"success": false,
|
||||||
|
"started_at": "2026-08-31T09:00:56.612675+00:00",
|
||||||
|
"finished_at": "2026-08-31T09:02:11.406524+00:00",
|
||||||
|
"terminal": {
|
||||||
|
"taskId": "cad_ccc4ec062035",
|
||||||
|
"lifecycle": "failed",
|
||||||
|
"code": "FAILED_AUTHOR_FORMAT",
|
||||||
|
"message": "Author repeatedly failed the same canonical schema."
|
||||||
|
},
|
||||||
|
"projection": {
|
||||||
|
"schema_version": "3.0",
|
||||||
|
"task_id": "cad_ccc4ec062035",
|
||||||
|
"phase": "FAILED",
|
||||||
|
"lifecycle": "failed",
|
||||||
|
"state_version": 4,
|
||||||
|
"active_revision": "",
|
||||||
|
"current_revision": "",
|
||||||
|
"published_revision": "",
|
||||||
|
"pending_action": null,
|
||||||
|
"active_candidate_id": "",
|
||||||
|
"repair_required": false,
|
||||||
|
"last_error": "FAILED_AUTHOR_FORMAT",
|
||||||
|
"action_ledger_summary": [
|
||||||
|
{
|
||||||
|
"sequence": 1,
|
||||||
|
"at": "2026-08-31 09:01:04",
|
||||||
|
"draft_revision": 1,
|
||||||
|
"event": "requirements_draft_updated",
|
||||||
|
"item_count": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sequence": 2,
|
||||||
|
"at": "2026-08-31 09:01:30",
|
||||||
|
"draft_revision": 2,
|
||||||
|
"event": "requirements_draft_patched",
|
||||||
|
"item_count": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sequence": 3,
|
||||||
|
"at": "2026-08-31 09:01:53",
|
||||||
|
"draft_revision": 3,
|
||||||
|
"event": "requirements_draft_patched",
|
||||||
|
"item_count": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sequence": 4,
|
||||||
|
"at": "2026-08-31 09:02:11",
|
||||||
|
"event": "failed_author_format",
|
||||||
|
"field_errors": [
|
||||||
|
{
|
||||||
|
"message": "Field required",
|
||||||
|
"path": "/patches/0/target_draft_id"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"message": "Extra inputs are not permitted",
|
||||||
|
"path": "/target_draft_id"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"tool": "patch_requirements_draft"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"revisions": []
|
||||||
|
},
|
||||||
|
"usage": {
|
||||||
|
"calls": 9,
|
||||||
|
"prompt_tokens": 55142,
|
||||||
|
"completion_tokens": 2943,
|
||||||
|
"context_chars": 15133,
|
||||||
|
"records": [
|
||||||
|
{
|
||||||
|
"completion_tokens": 385,
|
||||||
|
"context_chars": 836,
|
||||||
|
"model_id": "gpt-5.4-mini",
|
||||||
|
"prompt_tokens": 5910,
|
||||||
|
"provider_id": "openai",
|
||||||
|
"raw_arguments_hash": "c901d4c9931742387dfdd444508e5988a292648298da2b185857baea5349f213",
|
||||||
|
"tool": "submit_requirements_draft_batch",
|
||||||
|
"total_tokens": 6295
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"completion_tokens": 278,
|
||||||
|
"context_chars": 1518,
|
||||||
|
"model_id": "gpt-5.4-mini",
|
||||||
|
"prompt_tokens": 6098,
|
||||||
|
"provider_id": "openai",
|
||||||
|
"raw_arguments_hash": "54f21fc272547493351d211b86a1e4aeb420946bf7b65a56433f9096b9cf9f5c",
|
||||||
|
"tool": "submit_requirements_draft_batch",
|
||||||
|
"total_tokens": 6376
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"completion_tokens": 303,
|
||||||
|
"context_chars": 1919,
|
||||||
|
"model_id": "gpt-5.4-mini",
|
||||||
|
"prompt_tokens": 6178,
|
||||||
|
"provider_id": "openai",
|
||||||
|
"raw_arguments_hash": "23f239dab50941a8e43c1d717b7791115146edab9b9718d1478c9ae012fb1782",
|
||||||
|
"tool": "patch_requirements_draft",
|
||||||
|
"total_tokens": 6481
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"completion_tokens": 252,
|
||||||
|
"context_chars": 1993,
|
||||||
|
"model_id": "gpt-5.4-mini",
|
||||||
|
"prompt_tokens": 6202,
|
||||||
|
"provider_id": "openai",
|
||||||
|
"raw_arguments_hash": "33da3f40fbd2ce0cb9dad052f8d4f140dea658fbb0fdb475d3625c347621f4db",
|
||||||
|
"tool": "patch_requirements_draft",
|
||||||
|
"total_tokens": 6454
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"completion_tokens": 291,
|
||||||
|
"context_chars": 1518,
|
||||||
|
"model_id": "gpt-5.4-mini",
|
||||||
|
"prompt_tokens": 6098,
|
||||||
|
"provider_id": "openai",
|
||||||
|
"raw_arguments_hash": "6642d29cd838aaa3f6709e8f2a5edc1920e8c3caf9239e433ed66c321bb3af2b",
|
||||||
|
"tool": "submit_requirements_draft_batch",
|
||||||
|
"total_tokens": 6389
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"completion_tokens": 303,
|
||||||
|
"context_chars": 1919,
|
||||||
|
"model_id": "gpt-5.4-mini",
|
||||||
|
"prompt_tokens": 6178,
|
||||||
|
"provider_id": "openai",
|
||||||
|
"raw_arguments_hash": "232052bc3c6da6582f57a81f4061433188d4838c3f938ad1c7fff7c66049c653",
|
||||||
|
"tool": "patch_requirements_draft",
|
||||||
|
"total_tokens": 6481
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"completion_tokens": 334,
|
||||||
|
"context_chars": 1993,
|
||||||
|
"model_id": "gpt-5.4-mini",
|
||||||
|
"prompt_tokens": 6202,
|
||||||
|
"provider_id": "openai",
|
||||||
|
"raw_arguments_hash": "05d75ea8566bc896be811b0b2801ec3665ded0db0271a053e0ac6bb4be6cfd59",
|
||||||
|
"tool": "patch_requirements_draft",
|
||||||
|
"total_tokens": 6536
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"completion_tokens": 398,
|
||||||
|
"context_chars": 1518,
|
||||||
|
"model_id": "gpt-5.4-mini",
|
||||||
|
"prompt_tokens": 6098,
|
||||||
|
"provider_id": "openai",
|
||||||
|
"raw_arguments_hash": "7d6b3e0925d86ca6fbec794861924a8df0f4b51f56d074d558b864a43a2bc67e",
|
||||||
|
"tool": "submit_requirements_draft_batch",
|
||||||
|
"total_tokens": 6496
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"completion_tokens": 399,
|
||||||
|
"context_chars": 1919,
|
||||||
|
"model_id": "gpt-5.4-mini",
|
||||||
|
"prompt_tokens": 6178,
|
||||||
|
"provider_id": "openai",
|
||||||
|
"raw_arguments_hash": "1d13a8e2e056ed9b1b49885a9ab05599f58f51e4cecbd22a0640f3dc974b4872",
|
||||||
|
"tool": "patch_requirements_draft",
|
||||||
|
"total_tokens": 6577
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"checks": {
|
||||||
|
"expected_terminal_phase": false,
|
||||||
|
"token_budget": true,
|
||||||
|
"call_budget": true,
|
||||||
|
"author_turn_budget": true,
|
||||||
|
"reviewer_turn_budget": true,
|
||||||
|
"required_claims": false,
|
||||||
|
"required_operations": false,
|
||||||
|
"required_operation_alternative": true,
|
||||||
|
"immutable_artifacts": false,
|
||||||
|
"unique_revisions": true,
|
||||||
|
"raw_argument_audit": true,
|
||||||
|
"no_schema_or_decision_rejections": false
|
||||||
|
},
|
||||||
|
"rejection_codes": [
|
||||||
|
"AUTHOR_FORMAT_INVALID",
|
||||||
|
"AUTHOR_FORMAT_INVALID",
|
||||||
|
"AUTHOR_FORMAT_INVALID",
|
||||||
|
"AUTHOR_FORMAT_INVALID",
|
||||||
|
"AUTHOR_FORMAT_INVALID",
|
||||||
|
"AUTHOR_FORMAT_INVALID"
|
||||||
|
],
|
||||||
|
"artifact_root": "/Users/lk/Projects/cdsl-cad/backend/live-evals/20260831T085924Z/artifacts/cad_ccc4ec062035"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "simple_flange",
|
||||||
|
"repetition": 1,
|
||||||
|
"success": false,
|
||||||
|
"started_at": "2026-08-31T09:02:11.409883+00:00",
|
||||||
|
"finished_at": "2026-08-31T09:03:23.606881+00:00",
|
||||||
|
"terminal": {
|
||||||
|
"taskId": "cad_1a1c78be107f",
|
||||||
|
"lifecycle": "failed",
|
||||||
|
"code": "FAILED_AUTHOR_FORMAT",
|
||||||
|
"message": "Author repeatedly failed the same canonical schema."
|
||||||
|
},
|
||||||
|
"projection": {
|
||||||
|
"schema_version": "3.0",
|
||||||
|
"task_id": "cad_1a1c78be107f",
|
||||||
|
"phase": "FAILED",
|
||||||
|
"lifecycle": "failed",
|
||||||
|
"state_version": 3,
|
||||||
|
"active_revision": "",
|
||||||
|
"current_revision": "",
|
||||||
|
"published_revision": "",
|
||||||
|
"pending_action": null,
|
||||||
|
"active_candidate_id": "",
|
||||||
|
"repair_required": false,
|
||||||
|
"last_error": "FAILED_AUTHOR_FORMAT",
|
||||||
|
"action_ledger_summary": [
|
||||||
|
{
|
||||||
|
"sequence": 5,
|
||||||
|
"at": "2026-08-31 09:02:22",
|
||||||
|
"draft_revision": 1,
|
||||||
|
"event": "requirements_draft_updated",
|
||||||
|
"item_count": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sequence": 6,
|
||||||
|
"at": "2026-08-31 09:03:07",
|
||||||
|
"draft_revision": 2,
|
||||||
|
"event": "requirements_draft_patched",
|
||||||
|
"item_count": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sequence": 7,
|
||||||
|
"at": "2026-08-31 09:03:23",
|
||||||
|
"event": "failed_author_format",
|
||||||
|
"field_errors": [
|
||||||
|
{
|
||||||
|
"message": "Field required",
|
||||||
|
"path": "/patches/0/target_draft_id"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"message": "Extra inputs are not permitted",
|
||||||
|
"path": "/target_draft_id"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"tool": "patch_requirements_draft"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"revisions": []
|
||||||
|
},
|
||||||
|
"usage": {
|
||||||
|
"calls": 7,
|
||||||
|
"prompt_tokens": 43222,
|
||||||
|
"completion_tokens": 3167,
|
||||||
|
"context_chars": 13018,
|
||||||
|
"records": [
|
||||||
|
{
|
||||||
|
"completion_tokens": 412,
|
||||||
|
"context_chars": 892,
|
||||||
|
"model_id": "gpt-5.4-mini",
|
||||||
|
"prompt_tokens": 5918,
|
||||||
|
"provider_id": "openai",
|
||||||
|
"raw_arguments_hash": "5030e50bc35c2569181a2866da0ab2f10a7b23a88d6ef6e134c92f9976b6d949",
|
||||||
|
"tool": "submit_requirements_draft_batch",
|
||||||
|
"total_tokens": 6330
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"completion_tokens": 366,
|
||||||
|
"context_chars": 1729,
|
||||||
|
"model_id": "gpt-5.4-mini",
|
||||||
|
"prompt_tokens": 6156,
|
||||||
|
"provider_id": "openai",
|
||||||
|
"raw_arguments_hash": "29b6b77e6f42a319a373f7050e291647a60f4fd20940775f09411ac63140170d",
|
||||||
|
"tool": "submit_requirements_draft_batch",
|
||||||
|
"total_tokens": 6522
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"completion_tokens": 329,
|
||||||
|
"context_chars": 2130,
|
||||||
|
"model_id": "gpt-5.4-mini",
|
||||||
|
"prompt_tokens": 6236,
|
||||||
|
"provider_id": "openai",
|
||||||
|
"raw_arguments_hash": "be0d9112723c27fbae5139e29d93a5a88b0dd6b7a666e041816db9e515e40a86",
|
||||||
|
"tool": "patch_requirements_draft",
|
||||||
|
"total_tokens": 6565
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"completion_tokens": 751,
|
||||||
|
"context_chars": 2204,
|
||||||
|
"model_id": "gpt-5.4-mini",
|
||||||
|
"prompt_tokens": 6260,
|
||||||
|
"provider_id": "openai",
|
||||||
|
"raw_arguments_hash": "83f8db722fbbfebf45ad2a39588e9ea3c5d9281803287cf4eef4c80c1070c52c",
|
||||||
|
"tool": "patch_requirements_draft",
|
||||||
|
"total_tokens": 7011
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"completion_tokens": 638,
|
||||||
|
"context_chars": 2204,
|
||||||
|
"model_id": "gpt-5.4-mini",
|
||||||
|
"prompt_tokens": 6260,
|
||||||
|
"provider_id": "openai",
|
||||||
|
"raw_arguments_hash": "7dffc30397b3bb660a206bc55945db9a643e2221c0c83e1fb06de2fe880163fa",
|
||||||
|
"tool": "patch_requirements_draft",
|
||||||
|
"total_tokens": 6898
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"completion_tokens": 340,
|
||||||
|
"context_chars": 1729,
|
||||||
|
"model_id": "gpt-5.4-mini",
|
||||||
|
"prompt_tokens": 6156,
|
||||||
|
"provider_id": "openai",
|
||||||
|
"raw_arguments_hash": "29b6b77e6f42a319a373f7050e291647a60f4fd20940775f09411ac63140170d",
|
||||||
|
"tool": "submit_requirements_draft_batch",
|
||||||
|
"total_tokens": 6496
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"completion_tokens": 331,
|
||||||
|
"context_chars": 2130,
|
||||||
|
"model_id": "gpt-5.4-mini",
|
||||||
|
"prompt_tokens": 6236,
|
||||||
|
"provider_id": "openai",
|
||||||
|
"raw_arguments_hash": "6660f5b34c43201605c0b9b2d948bd928770f3cc185991687703193c61566ec9",
|
||||||
|
"tool": "patch_requirements_draft",
|
||||||
|
"total_tokens": 6567
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"checks": {
|
||||||
|
"expected_terminal_phase": false,
|
||||||
|
"token_budget": true,
|
||||||
|
"call_budget": true,
|
||||||
|
"author_turn_budget": true,
|
||||||
|
"reviewer_turn_budget": true,
|
||||||
|
"required_claims": false,
|
||||||
|
"required_operations": false,
|
||||||
|
"required_operation_alternative": true,
|
||||||
|
"immutable_artifacts": false,
|
||||||
|
"unique_revisions": true,
|
||||||
|
"raw_argument_audit": true,
|
||||||
|
"no_schema_or_decision_rejections": false
|
||||||
|
},
|
||||||
|
"rejection_codes": [
|
||||||
|
"AUTHOR_FORMAT_INVALID",
|
||||||
|
"AUTHOR_FORMAT_INVALID",
|
||||||
|
"AUTHOR_FORMAT_INVALID",
|
||||||
|
"AUTHOR_FORMAT_INVALID",
|
||||||
|
"AUTHOR_FORMAT_INVALID"
|
||||||
|
],
|
||||||
|
"artifact_root": "/Users/lk/Projects/cdsl-cad/backend/live-evals/20260831T085924Z/artifacts/cad_1a1c78be107f"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"suite": "smoke",
|
||||||
|
"require_live": true,
|
||||||
|
"report_root": "/Users/lk/Projects/cdsl-cad/backend/live-evals/20260831T085924Z"
|
||||||
|
}
|
||||||
@@ -0,0 +1,452 @@
|
|||||||
|
{
|
||||||
|
"action_id": "act_004",
|
||||||
|
"actual_atomic_id": "extrude_add_blind",
|
||||||
|
"blockers": [],
|
||||||
|
"candidate_id": "candidate_0754a6ee423cfb74",
|
||||||
|
"claim_results": [
|
||||||
|
{
|
||||||
|
"claim_id": "claim_001",
|
||||||
|
"claim_kind": "solid_count_equals",
|
||||||
|
"deterministic": true,
|
||||||
|
"evidence": {
|
||||||
|
"solid_count": 1
|
||||||
|
},
|
||||||
|
"status": "pass"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"claim_id": "claim_002",
|
||||||
|
"claim_kind": "single_connected_body",
|
||||||
|
"deterministic": true,
|
||||||
|
"evidence": {
|
||||||
|
"solid_count": 1
|
||||||
|
},
|
||||||
|
"status": "pass"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"claim_id": "claim_003",
|
||||||
|
"claim_kind": "bbox_dimension_mm",
|
||||||
|
"deterministic": true,
|
||||||
|
"evidence": {
|
||||||
|
"actual_mm": 80.0,
|
||||||
|
"axis": "x",
|
||||||
|
"tolerance_mm": 0.0
|
||||||
|
},
|
||||||
|
"status": "pass"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"claim_id": "claim_004",
|
||||||
|
"claim_kind": "bbox_dimension_mm",
|
||||||
|
"deterministic": true,
|
||||||
|
"evidence": {
|
||||||
|
"actual_mm": 50.0,
|
||||||
|
"axis": "y",
|
||||||
|
"tolerance_mm": 0.0
|
||||||
|
},
|
||||||
|
"status": "pass"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"claim_id": "claim_005",
|
||||||
|
"claim_kind": "bbox_dimension_mm",
|
||||||
|
"deterministic": true,
|
||||||
|
"evidence": {
|
||||||
|
"actual_mm": 8.0,
|
||||||
|
"axis": "z",
|
||||||
|
"tolerance_mm": 0.0
|
||||||
|
},
|
||||||
|
"status": "pass"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fragment_hash": "34b4567ab12fdc5a54b67ab39a791a6a0550daeb157fe6e40abd7f67726046bb",
|
||||||
|
"health": {
|
||||||
|
"bbox_mm": {
|
||||||
|
"dimensions": [
|
||||||
|
80.0,
|
||||||
|
50.0,
|
||||||
|
8.0
|
||||||
|
],
|
||||||
|
"max": [
|
||||||
|
80.0,
|
||||||
|
50.0,
|
||||||
|
8.0
|
||||||
|
],
|
||||||
|
"min": [
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
0.0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"feature_count": 1,
|
||||||
|
"solid_count": 1,
|
||||||
|
"volume_mm3": 31999.999999999996
|
||||||
|
},
|
||||||
|
"paths": {
|
||||||
|
"cdsl": "model.cdsl.json",
|
||||||
|
"glb": "model.glb",
|
||||||
|
"render_manifest": "renders/render-manifest.json",
|
||||||
|
"report": "rebuild-report.json",
|
||||||
|
"step": "model.step",
|
||||||
|
"topology": "model.topology.json"
|
||||||
|
},
|
||||||
|
"render_manifest": {
|
||||||
|
"contact_sheet_path": "/Users/lk/Projects/cdsl-cad/backend/live-evals/20260831T090523Z/artifacts/cad_a9dc4e5dbc1a/.staging/stage_0754a6ee423cfb745d93/renders/contact-sheet.jpg",
|
||||||
|
"high_resolution": {
|
||||||
|
"height": 2048,
|
||||||
|
"method": "occ_hidden_line",
|
||||||
|
"width": 2048
|
||||||
|
},
|
||||||
|
"renderer": "python-occ-hlr-pillow",
|
||||||
|
"review_resolution": {
|
||||||
|
"height": 1024,
|
||||||
|
"resample": "lanczos",
|
||||||
|
"width": 1024
|
||||||
|
},
|
||||||
|
"schema_version": "cad.render-manifest.v2",
|
||||||
|
"source": {
|
||||||
|
"bounds_mm": [
|
||||||
|
0.0,
|
||||||
|
80.0,
|
||||||
|
0.0,
|
||||||
|
50.0,
|
||||||
|
0.0,
|
||||||
|
8.0
|
||||||
|
],
|
||||||
|
"path": "/Users/lk/Projects/cdsl-cad/backend/live-evals/20260831T090523Z/artifacts/cad_a9dc4e5dbc1a/.staging/stage_0754a6ee423cfb745d93/model.step",
|
||||||
|
"type": "step"
|
||||||
|
},
|
||||||
|
"views": [
|
||||||
|
{
|
||||||
|
"camera": {
|
||||||
|
"focal_point": [
|
||||||
|
40.0,
|
||||||
|
25.0,
|
||||||
|
4.0
|
||||||
|
],
|
||||||
|
"frame_mm": [
|
||||||
|
-55.55555555555556,
|
||||||
|
55.55555555555556,
|
||||||
|
-55.55555555555556,
|
||||||
|
55.55555555555556
|
||||||
|
],
|
||||||
|
"position": [
|
||||||
|
40.0,
|
||||||
|
25.0,
|
||||||
|
100004.0
|
||||||
|
],
|
||||||
|
"projection": "orthographic",
|
||||||
|
"view": "top",
|
||||||
|
"view_up": [
|
||||||
|
0.0,
|
||||||
|
1.0,
|
||||||
|
0.0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"diagnostics": {
|
||||||
|
"coverage": 0.005703926086425781,
|
||||||
|
"edge_path": "/Users/lk/Projects/cdsl-cad/backend/live-evals/20260831T090523Z/artifacts/cad_a9dc4e5dbc1a/.staging/stage_0754a6ee423cfb745d93/renders/internal/top/edge.png",
|
||||||
|
"hidden_edge_count": 4,
|
||||||
|
"intentional_crop": false,
|
||||||
|
"line_mask_path": "/Users/lk/Projects/cdsl-cad/backend/live-evals/20260831T090523Z/artifacts/cad_a9dc4e5dbc1a/.staging/stage_0754a6ee423cfb745d93/renders/internal/top/line-mask.png",
|
||||||
|
"pixel_bbox": [
|
||||||
|
285,
|
||||||
|
561,
|
||||||
|
1763,
|
||||||
|
1487
|
||||||
|
],
|
||||||
|
"touches_border": false,
|
||||||
|
"valid": true,
|
||||||
|
"visible_edge_count": 4
|
||||||
|
},
|
||||||
|
"high_resolution_path": "/Users/lk/Projects/cdsl-cad/backend/live-evals/20260831T090523Z/artifacts/cad_a9dc4e5dbc1a/.staging/stage_0754a6ee423cfb745d93/renders/internal/top-2x.png",
|
||||||
|
"id": "top",
|
||||||
|
"path": "/Users/lk/Projects/cdsl-cad/backend/live-evals/20260831T090523Z/artifacts/cad_a9dc4e5dbc1a/.staging/stage_0754a6ee423cfb745d93/renders/top.png",
|
||||||
|
"target": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"camera": {
|
||||||
|
"focal_point": [
|
||||||
|
40.0,
|
||||||
|
25.0,
|
||||||
|
4.0
|
||||||
|
],
|
||||||
|
"frame_mm": [
|
||||||
|
-55.55555555555556,
|
||||||
|
55.55555555555556,
|
||||||
|
-55.55555555555556,
|
||||||
|
55.55555555555556
|
||||||
|
],
|
||||||
|
"position": [
|
||||||
|
40.0,
|
||||||
|
25.0,
|
||||||
|
-99996.0
|
||||||
|
],
|
||||||
|
"projection": "orthographic",
|
||||||
|
"view": "bottom",
|
||||||
|
"view_up": [
|
||||||
|
0.0,
|
||||||
|
1.0,
|
||||||
|
0.0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"diagnostics": {
|
||||||
|
"coverage": 0.005703926086425781,
|
||||||
|
"edge_path": "/Users/lk/Projects/cdsl-cad/backend/live-evals/20260831T090523Z/artifacts/cad_a9dc4e5dbc1a/.staging/stage_0754a6ee423cfb745d93/renders/internal/bottom/edge.png",
|
||||||
|
"hidden_edge_count": 4,
|
||||||
|
"intentional_crop": false,
|
||||||
|
"line_mask_path": "/Users/lk/Projects/cdsl-cad/backend/live-evals/20260831T090523Z/artifacts/cad_a9dc4e5dbc1a/.staging/stage_0754a6ee423cfb745d93/renders/internal/bottom/line-mask.png",
|
||||||
|
"pixel_bbox": [
|
||||||
|
285,
|
||||||
|
561,
|
||||||
|
1763,
|
||||||
|
1487
|
||||||
|
],
|
||||||
|
"touches_border": false,
|
||||||
|
"valid": true,
|
||||||
|
"visible_edge_count": 4
|
||||||
|
},
|
||||||
|
"high_resolution_path": "/Users/lk/Projects/cdsl-cad/backend/live-evals/20260831T090523Z/artifacts/cad_a9dc4e5dbc1a/.staging/stage_0754a6ee423cfb745d93/renders/internal/bottom-2x.png",
|
||||||
|
"id": "bottom",
|
||||||
|
"path": "/Users/lk/Projects/cdsl-cad/backend/live-evals/20260831T090523Z/artifacts/cad_a9dc4e5dbc1a/.staging/stage_0754a6ee423cfb745d93/renders/bottom.png",
|
||||||
|
"target": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"camera": {
|
||||||
|
"focal_point": [
|
||||||
|
40.0,
|
||||||
|
25.0,
|
||||||
|
4.0
|
||||||
|
],
|
||||||
|
"frame_mm": [
|
||||||
|
-55.55555555555556,
|
||||||
|
55.55555555555556,
|
||||||
|
-55.55555555555556,
|
||||||
|
55.55555555555556
|
||||||
|
],
|
||||||
|
"position": [
|
||||||
|
40.0,
|
||||||
|
-99975.0,
|
||||||
|
4.0
|
||||||
|
],
|
||||||
|
"projection": "orthographic",
|
||||||
|
"view": "front",
|
||||||
|
"view_up": [
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
1.0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"diagnostics": {
|
||||||
|
"coverage": 0.0038585662841796875,
|
||||||
|
"edge_path": "/Users/lk/Projects/cdsl-cad/backend/live-evals/20260831T090523Z/artifacts/cad_a9dc4e5dbc1a/.staging/stage_0754a6ee423cfb745d93/renders/internal/front/edge.png",
|
||||||
|
"hidden_edge_count": 4,
|
||||||
|
"intentional_crop": false,
|
||||||
|
"line_mask_path": "/Users/lk/Projects/cdsl-cad/backend/live-evals/20260831T090523Z/artifacts/cad_a9dc4e5dbc1a/.staging/stage_0754a6ee423cfb745d93/renders/internal/front/line-mask.png",
|
||||||
|
"pixel_bbox": [
|
||||||
|
285,
|
||||||
|
948,
|
||||||
|
1763,
|
||||||
|
1100
|
||||||
|
],
|
||||||
|
"touches_border": false,
|
||||||
|
"valid": true,
|
||||||
|
"visible_edge_count": 4
|
||||||
|
},
|
||||||
|
"high_resolution_path": "/Users/lk/Projects/cdsl-cad/backend/live-evals/20260831T090523Z/artifacts/cad_a9dc4e5dbc1a/.staging/stage_0754a6ee423cfb745d93/renders/internal/front-2x.png",
|
||||||
|
"id": "front",
|
||||||
|
"path": "/Users/lk/Projects/cdsl-cad/backend/live-evals/20260831T090523Z/artifacts/cad_a9dc4e5dbc1a/.staging/stage_0754a6ee423cfb745d93/renders/front.png",
|
||||||
|
"target": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"camera": {
|
||||||
|
"focal_point": [
|
||||||
|
40.0,
|
||||||
|
25.0,
|
||||||
|
4.0
|
||||||
|
],
|
||||||
|
"frame_mm": [
|
||||||
|
-55.55555555555556,
|
||||||
|
55.55555555555556,
|
||||||
|
-55.55555555555556,
|
||||||
|
55.55555555555556
|
||||||
|
],
|
||||||
|
"position": [
|
||||||
|
40.0,
|
||||||
|
100025.0,
|
||||||
|
4.0
|
||||||
|
],
|
||||||
|
"projection": "orthographic",
|
||||||
|
"view": "back",
|
||||||
|
"view_up": [
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
1.0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"diagnostics": {
|
||||||
|
"coverage": 0.0038585662841796875,
|
||||||
|
"edge_path": "/Users/lk/Projects/cdsl-cad/backend/live-evals/20260831T090523Z/artifacts/cad_a9dc4e5dbc1a/.staging/stage_0754a6ee423cfb745d93/renders/internal/back/edge.png",
|
||||||
|
"hidden_edge_count": 4,
|
||||||
|
"intentional_crop": false,
|
||||||
|
"line_mask_path": "/Users/lk/Projects/cdsl-cad/backend/live-evals/20260831T090523Z/artifacts/cad_a9dc4e5dbc1a/.staging/stage_0754a6ee423cfb745d93/renders/internal/back/line-mask.png",
|
||||||
|
"pixel_bbox": [
|
||||||
|
285,
|
||||||
|
948,
|
||||||
|
1763,
|
||||||
|
1100
|
||||||
|
],
|
||||||
|
"touches_border": false,
|
||||||
|
"valid": true,
|
||||||
|
"visible_edge_count": 4
|
||||||
|
},
|
||||||
|
"high_resolution_path": "/Users/lk/Projects/cdsl-cad/backend/live-evals/20260831T090523Z/artifacts/cad_a9dc4e5dbc1a/.staging/stage_0754a6ee423cfb745d93/renders/internal/back-2x.png",
|
||||||
|
"id": "back",
|
||||||
|
"path": "/Users/lk/Projects/cdsl-cad/backend/live-evals/20260831T090523Z/artifacts/cad_a9dc4e5dbc1a/.staging/stage_0754a6ee423cfb745d93/renders/back.png",
|
||||||
|
"target": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"camera": {
|
||||||
|
"focal_point": [
|
||||||
|
40.0,
|
||||||
|
25.0,
|
||||||
|
4.0
|
||||||
|
],
|
||||||
|
"frame_mm": [
|
||||||
|
-34.72222222222222,
|
||||||
|
34.72222222222222,
|
||||||
|
-34.72222222222222,
|
||||||
|
34.72222222222222
|
||||||
|
],
|
||||||
|
"position": [
|
||||||
|
-99960.0,
|
||||||
|
25.0,
|
||||||
|
4.0
|
||||||
|
],
|
||||||
|
"projection": "orthographic",
|
||||||
|
"view": "left",
|
||||||
|
"view_up": [
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
1.0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"diagnostics": {
|
||||||
|
"coverage": 0.0040683746337890625,
|
||||||
|
"edge_path": "/Users/lk/Projects/cdsl-cad/backend/live-evals/20260831T090523Z/artifacts/cad_a9dc4e5dbc1a/.staging/stage_0754a6ee423cfb745d93/renders/internal/left/edge.png",
|
||||||
|
"hidden_edge_count": 4,
|
||||||
|
"intentional_crop": false,
|
||||||
|
"line_mask_path": "/Users/lk/Projects/cdsl-cad/backend/live-evals/20260831T090523Z/artifacts/cad_a9dc4e5dbc1a/.staging/stage_0754a6ee423cfb745d93/renders/internal/left/line-mask.png",
|
||||||
|
"pixel_bbox": [
|
||||||
|
285,
|
||||||
|
904,
|
||||||
|
1763,
|
||||||
|
1144
|
||||||
|
],
|
||||||
|
"touches_border": false,
|
||||||
|
"valid": true,
|
||||||
|
"visible_edge_count": 4
|
||||||
|
},
|
||||||
|
"high_resolution_path": "/Users/lk/Projects/cdsl-cad/backend/live-evals/20260831T090523Z/artifacts/cad_a9dc4e5dbc1a/.staging/stage_0754a6ee423cfb745d93/renders/internal/left-2x.png",
|
||||||
|
"id": "left",
|
||||||
|
"path": "/Users/lk/Projects/cdsl-cad/backend/live-evals/20260831T090523Z/artifacts/cad_a9dc4e5dbc1a/.staging/stage_0754a6ee423cfb745d93/renders/left.png",
|
||||||
|
"target": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"camera": {
|
||||||
|
"focal_point": [
|
||||||
|
40.0,
|
||||||
|
25.0,
|
||||||
|
4.0
|
||||||
|
],
|
||||||
|
"frame_mm": [
|
||||||
|
-34.72222222222222,
|
||||||
|
34.72222222222222,
|
||||||
|
-34.72222222222222,
|
||||||
|
34.72222222222222
|
||||||
|
],
|
||||||
|
"position": [
|
||||||
|
100040.0,
|
||||||
|
25.0,
|
||||||
|
4.0
|
||||||
|
],
|
||||||
|
"projection": "orthographic",
|
||||||
|
"view": "right",
|
||||||
|
"view_up": [
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
1.0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"diagnostics": {
|
||||||
|
"coverage": 0.0040683746337890625,
|
||||||
|
"edge_path": "/Users/lk/Projects/cdsl-cad/backend/live-evals/20260831T090523Z/artifacts/cad_a9dc4e5dbc1a/.staging/stage_0754a6ee423cfb745d93/renders/internal/right/edge.png",
|
||||||
|
"hidden_edge_count": 4,
|
||||||
|
"intentional_crop": false,
|
||||||
|
"line_mask_path": "/Users/lk/Projects/cdsl-cad/backend/live-evals/20260831T090523Z/artifacts/cad_a9dc4e5dbc1a/.staging/stage_0754a6ee423cfb745d93/renders/internal/right/line-mask.png",
|
||||||
|
"pixel_bbox": [
|
||||||
|
285,
|
||||||
|
904,
|
||||||
|
1763,
|
||||||
|
1144
|
||||||
|
],
|
||||||
|
"touches_border": false,
|
||||||
|
"valid": true,
|
||||||
|
"visible_edge_count": 4
|
||||||
|
},
|
||||||
|
"high_resolution_path": "/Users/lk/Projects/cdsl-cad/backend/live-evals/20260831T090523Z/artifacts/cad_a9dc4e5dbc1a/.staging/stage_0754a6ee423cfb745d93/renders/internal/right-2x.png",
|
||||||
|
"id": "right",
|
||||||
|
"path": "/Users/lk/Projects/cdsl-cad/backend/live-evals/20260831T090523Z/artifacts/cad_a9dc4e5dbc1a/.staging/stage_0754a6ee423cfb745d93/renders/right.png",
|
||||||
|
"target": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"camera": {
|
||||||
|
"focal_point": [
|
||||||
|
40.0,
|
||||||
|
25.0,
|
||||||
|
4.0
|
||||||
|
],
|
||||||
|
"frame_mm": [
|
||||||
|
-63.836028857115835,
|
||||||
|
63.83602885712192,
|
||||||
|
-63.83602885712606,
|
||||||
|
63.83602885711169
|
||||||
|
],
|
||||||
|
"position": [
|
||||||
|
61585.74548966637,
|
||||||
|
-61520.74548966637,
|
||||||
|
49240.596391733096
|
||||||
|
],
|
||||||
|
"projection": "orthographic",
|
||||||
|
"view": "isometric",
|
||||||
|
"view_up": [
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
1.0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"diagnostics": {
|
||||||
|
"coverage": 0.0075147151947021484,
|
||||||
|
"edge_path": "/Users/lk/Projects/cdsl-cad/backend/live-evals/20260831T090523Z/artifacts/cad_a9dc4e5dbc1a/.staging/stage_0754a6ee423cfb745d93/renders/internal/isometric/edge.png",
|
||||||
|
"hidden_edge_count": 3,
|
||||||
|
"intentional_crop": false,
|
||||||
|
"line_mask_path": "/Users/lk/Projects/cdsl-cad/backend/live-evals/20260831T090523Z/artifacts/cad_a9dc4e5dbc1a/.staging/stage_0754a6ee423cfb745d93/renders/internal/isometric/line-mask.png",
|
||||||
|
"pixel_bbox": [
|
||||||
|
285,
|
||||||
|
603,
|
||||||
|
1763,
|
||||||
|
1445
|
||||||
|
],
|
||||||
|
"touches_border": false,
|
||||||
|
"valid": true,
|
||||||
|
"visible_edge_count": 9
|
||||||
|
},
|
||||||
|
"high_resolution_path": "/Users/lk/Projects/cdsl-cad/backend/live-evals/20260831T090523Z/artifacts/cad_a9dc4e5dbc1a/.staging/stage_0754a6ee423cfb745d93/renders/internal/isometric-2x.png",
|
||||||
|
"id": "isometric",
|
||||||
|
"path": "/Users/lk/Projects/cdsl-cad/backend/live-evals/20260831T090523Z/artifacts/cad_a9dc4e5dbc1a/.staging/stage_0754a6ee423cfb745d93/renders/isometric.png",
|
||||||
|
"target": null
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"schema_version": "cad.v3.candidate.v1",
|
||||||
|
"selector_snapshot_id": "",
|
||||||
|
"stage_id": "stage_0754a6ee423cfb745d93",
|
||||||
|
"working_head": "cad_a9dc4e5dbc1a:root:v3"
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
{
|
||||||
|
"action_id": "act_004",
|
||||||
|
"candidate_id": "candidate_0754a6ee423cfb74",
|
||||||
|
"contract_hash": "fa9ffc9fd2141453fa18a389813dde2e2ef3fb4c7ef5d05e61d51d614309e95f",
|
||||||
|
"fragment": {
|
||||||
|
"feature": {
|
||||||
|
"atomic_id": "extrude_add_blind",
|
||||||
|
"params": {
|
||||||
|
"distance_mm": 8
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"sketch": {
|
||||||
|
"profile": {
|
||||||
|
"type": "polygon",
|
||||||
|
"vertices": [
|
||||||
|
[
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
[
|
||||||
|
80,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
[
|
||||||
|
80,
|
||||||
|
50
|
||||||
|
],
|
||||||
|
[
|
||||||
|
0,
|
||||||
|
50
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"workplane": {
|
||||||
|
"normal": [
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
1
|
||||||
|
],
|
||||||
|
"origin_mm": [
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"x_dir": [
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fragment_audit": {
|
||||||
|
"assigned_feature_ids": [
|
||||||
|
"feature_001"
|
||||||
|
],
|
||||||
|
"assigned_sketch_ids": [
|
||||||
|
"sketch_001"
|
||||||
|
],
|
||||||
|
"atomic_id": "extrude_add_blind",
|
||||||
|
"contract_hash": "fa9ffc9fd2141453fa18a389813dde2e2ef3fb4c7ef5d05e61d51d614309e95f",
|
||||||
|
"fragment_hash": "34b4567ab12fdc5a54b67ab39a791a6a0550daeb157fe6e40abd7f67726046bb",
|
||||||
|
"reference_snapshot_id": "44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a",
|
||||||
|
"reference_tokens": [],
|
||||||
|
"schema_version": "cad.v3.fragment-audit.v1",
|
||||||
|
"selector_snapshot_id": "",
|
||||||
|
"selector_tokens": []
|
||||||
|
},
|
||||||
|
"idempotency_key": "64af92e434bcdea7ea2a010b5dc08c004ffaedd9b15a396ccf2904b4ddf73760",
|
||||||
|
"schema_version": "cad.v3.candidate-input.v1",
|
||||||
|
"working_head": "cad_a9dc4e5dbc1a:root:v3"
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
{
|
||||||
|
"schema": "cad.cdsl.llm.v1",
|
||||||
|
"schema_version": "1.1.0",
|
||||||
|
"kind": "part",
|
||||||
|
"part_id": "cad_a9dc4e5dbc1a",
|
||||||
|
"geometry": {
|
||||||
|
"sketches": [
|
||||||
|
{
|
||||||
|
"id": "sketch_001",
|
||||||
|
"workplane": {
|
||||||
|
"origin_mm": [
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"normal": [
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
1
|
||||||
|
],
|
||||||
|
"x_dir": [
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"profile": {
|
||||||
|
"type": "polygon",
|
||||||
|
"vertices": [
|
||||||
|
[
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
[
|
||||||
|
80,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
[
|
||||||
|
80,
|
||||||
|
50
|
||||||
|
],
|
||||||
|
[
|
||||||
|
0,
|
||||||
|
50
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"features": [
|
||||||
|
{
|
||||||
|
"id": "feature_001",
|
||||||
|
"atomic_id": "extrude_add_blind",
|
||||||
|
"params": {
|
||||||
|
"distance_mm": 8
|
||||||
|
},
|
||||||
|
"depends_on": [],
|
||||||
|
"sketch_id": "sketch_001"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,415 @@
|
|||||||
|
ISO-10303-21;
|
||||||
|
HEADER;
|
||||||
|
FILE_DESCRIPTION(('Open CASCADE Model'),'2;1');
|
||||||
|
FILE_NAME('Open CASCADE Shape Model','2026-08-31T17:07:23',('Author'),(
|
||||||
|
'Open CASCADE'),'Open CASCADE STEP processor 7.9','build123d',
|
||||||
|
'Unknown');
|
||||||
|
FILE_SCHEMA(('AUTOMOTIVE_DESIGN { 1 0 10303 214 1 1 1 1 }'));
|
||||||
|
ENDSEC;
|
||||||
|
DATA;
|
||||||
|
#1 = APPLICATION_PROTOCOL_DEFINITION('international standard',
|
||||||
|
'automotive_design',2000,#2);
|
||||||
|
#2 = APPLICATION_CONTEXT(
|
||||||
|
'core data for automotive mechanical design processes');
|
||||||
|
#3 = SHAPE_DEFINITION_REPRESENTATION(#4,#10);
|
||||||
|
#4 = PRODUCT_DEFINITION_SHAPE('','',#5);
|
||||||
|
#5 = PRODUCT_DEFINITION('design','',#6,#9);
|
||||||
|
#6 = PRODUCT_DEFINITION_FORMATION('','',#7);
|
||||||
|
#7 = PRODUCT('SOLID','SOLID','',(#8));
|
||||||
|
#8 = PRODUCT_CONTEXT('',#2,'mechanical');
|
||||||
|
#9 = PRODUCT_DEFINITION_CONTEXT('part definition',#2,'design');
|
||||||
|
#10 = ADVANCED_BREP_SHAPE_REPRESENTATION('',(#11,#15),#345);
|
||||||
|
#11 = AXIS2_PLACEMENT_3D('',#12,#13,#14);
|
||||||
|
#12 = CARTESIAN_POINT('',(0.,0.,0.));
|
||||||
|
#13 = DIRECTION('',(0.,0.,1.));
|
||||||
|
#14 = DIRECTION('',(1.,0.,-0.));
|
||||||
|
#15 = MANIFOLD_SOLID_BREP('',#16);
|
||||||
|
#16 = CLOSED_SHELL('',(#17,#137,#213,#284,#331,#338));
|
||||||
|
#17 = ADVANCED_FACE('',(#18),#32,.F.);
|
||||||
|
#18 = FACE_BOUND('',#19,.F.);
|
||||||
|
#19 = EDGE_LOOP('',(#20,#55,#83,#111));
|
||||||
|
#20 = ORIENTED_EDGE('',*,*,#21,.T.);
|
||||||
|
#21 = EDGE_CURVE('',#22,#24,#26,.T.);
|
||||||
|
#22 = VERTEX_POINT('',#23);
|
||||||
|
#23 = CARTESIAN_POINT('',(0.,0.,0.));
|
||||||
|
#24 = VERTEX_POINT('',#25);
|
||||||
|
#25 = CARTESIAN_POINT('',(0.,0.,8.));
|
||||||
|
#26 = SURFACE_CURVE('',#27,(#31,#43),.PCURVE_S1.);
|
||||||
|
#27 = LINE('',#28,#29);
|
||||||
|
#28 = CARTESIAN_POINT('',(0.,0.,0.));
|
||||||
|
#29 = VECTOR('',#30,1.);
|
||||||
|
#30 = DIRECTION('',(0.,0.,1.));
|
||||||
|
#31 = PCURVE('',#32,#37);
|
||||||
|
#32 = PLANE('',#33);
|
||||||
|
#33 = AXIS2_PLACEMENT_3D('',#34,#35,#36);
|
||||||
|
#34 = CARTESIAN_POINT('',(0.,0.,0.));
|
||||||
|
#35 = DIRECTION('',(0.,1.,0.));
|
||||||
|
#36 = DIRECTION('',(1.,0.,0.));
|
||||||
|
#37 = DEFINITIONAL_REPRESENTATION('',(#38),#42);
|
||||||
|
#38 = LINE('',#39,#40);
|
||||||
|
#39 = CARTESIAN_POINT('',(0.,0.));
|
||||||
|
#40 = VECTOR('',#41,1.);
|
||||||
|
#41 = DIRECTION('',(0.,-1.));
|
||||||
|
#42 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2)
|
||||||
|
PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE',''
|
||||||
|
) );
|
||||||
|
#43 = PCURVE('',#44,#49);
|
||||||
|
#44 = PLANE('',#45);
|
||||||
|
#45 = AXIS2_PLACEMENT_3D('',#46,#47,#48);
|
||||||
|
#46 = CARTESIAN_POINT('',(0.,50.,0.));
|
||||||
|
#47 = DIRECTION('',(1.,0.,-0.));
|
||||||
|
#48 = DIRECTION('',(0.,-1.,0.));
|
||||||
|
#49 = DEFINITIONAL_REPRESENTATION('',(#50),#54);
|
||||||
|
#50 = LINE('',#51,#52);
|
||||||
|
#51 = CARTESIAN_POINT('',(50.,0.));
|
||||||
|
#52 = VECTOR('',#53,1.);
|
||||||
|
#53 = DIRECTION('',(0.,-1.));
|
||||||
|
#54 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2)
|
||||||
|
PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE',''
|
||||||
|
) );
|
||||||
|
#55 = ORIENTED_EDGE('',*,*,#56,.T.);
|
||||||
|
#56 = EDGE_CURVE('',#24,#57,#59,.T.);
|
||||||
|
#57 = VERTEX_POINT('',#58);
|
||||||
|
#58 = CARTESIAN_POINT('',(80.,0.,8.));
|
||||||
|
#59 = SURFACE_CURVE('',#60,(#64,#71),.PCURVE_S1.);
|
||||||
|
#60 = LINE('',#61,#62);
|
||||||
|
#61 = CARTESIAN_POINT('',(0.,0.,8.));
|
||||||
|
#62 = VECTOR('',#63,1.);
|
||||||
|
#63 = DIRECTION('',(1.,0.,0.));
|
||||||
|
#64 = PCURVE('',#32,#65);
|
||||||
|
#65 = DEFINITIONAL_REPRESENTATION('',(#66),#70);
|
||||||
|
#66 = LINE('',#67,#68);
|
||||||
|
#67 = CARTESIAN_POINT('',(0.,-8.));
|
||||||
|
#68 = VECTOR('',#69,1.);
|
||||||
|
#69 = DIRECTION('',(1.,0.));
|
||||||
|
#70 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2)
|
||||||
|
PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE',''
|
||||||
|
) );
|
||||||
|
#71 = PCURVE('',#72,#77);
|
||||||
|
#72 = PLANE('',#73);
|
||||||
|
#73 = AXIS2_PLACEMENT_3D('',#74,#75,#76);
|
||||||
|
#74 = CARTESIAN_POINT('',(40.,25.,8.));
|
||||||
|
#75 = DIRECTION('',(0.,0.,1.));
|
||||||
|
#76 = DIRECTION('',(1.,0.,-0.));
|
||||||
|
#77 = DEFINITIONAL_REPRESENTATION('',(#78),#82);
|
||||||
|
#78 = LINE('',#79,#80);
|
||||||
|
#79 = CARTESIAN_POINT('',(-40.,-25.));
|
||||||
|
#80 = VECTOR('',#81,1.);
|
||||||
|
#81 = DIRECTION('',(1.,0.));
|
||||||
|
#82 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2)
|
||||||
|
PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE',''
|
||||||
|
) );
|
||||||
|
#83 = ORIENTED_EDGE('',*,*,#84,.F.);
|
||||||
|
#84 = EDGE_CURVE('',#85,#57,#87,.T.);
|
||||||
|
#85 = VERTEX_POINT('',#86);
|
||||||
|
#86 = CARTESIAN_POINT('',(80.,0.,0.));
|
||||||
|
#87 = SURFACE_CURVE('',#88,(#92,#99),.PCURVE_S1.);
|
||||||
|
#88 = LINE('',#89,#90);
|
||||||
|
#89 = CARTESIAN_POINT('',(80.,0.,0.));
|
||||||
|
#90 = VECTOR('',#91,1.);
|
||||||
|
#91 = DIRECTION('',(0.,0.,1.));
|
||||||
|
#92 = PCURVE('',#32,#93);
|
||||||
|
#93 = DEFINITIONAL_REPRESENTATION('',(#94),#98);
|
||||||
|
#94 = LINE('',#95,#96);
|
||||||
|
#95 = CARTESIAN_POINT('',(80.,0.));
|
||||||
|
#96 = VECTOR('',#97,1.);
|
||||||
|
#97 = DIRECTION('',(0.,-1.));
|
||||||
|
#98 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2)
|
||||||
|
PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE',''
|
||||||
|
) );
|
||||||
|
#99 = PCURVE('',#100,#105);
|
||||||
|
#100 = PLANE('',#101);
|
||||||
|
#101 = AXIS2_PLACEMENT_3D('',#102,#103,#104);
|
||||||
|
#102 = CARTESIAN_POINT('',(80.,0.,0.));
|
||||||
|
#103 = DIRECTION('',(-1.,0.,0.));
|
||||||
|
#104 = DIRECTION('',(0.,1.,0.));
|
||||||
|
#105 = DEFINITIONAL_REPRESENTATION('',(#106),#110);
|
||||||
|
#106 = LINE('',#107,#108);
|
||||||
|
#107 = CARTESIAN_POINT('',(0.,0.));
|
||||||
|
#108 = VECTOR('',#109,1.);
|
||||||
|
#109 = DIRECTION('',(0.,-1.));
|
||||||
|
#110 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2)
|
||||||
|
PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE',''
|
||||||
|
) );
|
||||||
|
#111 = ORIENTED_EDGE('',*,*,#112,.F.);
|
||||||
|
#112 = EDGE_CURVE('',#22,#85,#113,.T.);
|
||||||
|
#113 = SURFACE_CURVE('',#114,(#118,#125),.PCURVE_S1.);
|
||||||
|
#114 = LINE('',#115,#116);
|
||||||
|
#115 = CARTESIAN_POINT('',(0.,0.,0.));
|
||||||
|
#116 = VECTOR('',#117,1.);
|
||||||
|
#117 = DIRECTION('',(1.,0.,0.));
|
||||||
|
#118 = PCURVE('',#32,#119);
|
||||||
|
#119 = DEFINITIONAL_REPRESENTATION('',(#120),#124);
|
||||||
|
#120 = LINE('',#121,#122);
|
||||||
|
#121 = CARTESIAN_POINT('',(0.,0.));
|
||||||
|
#122 = VECTOR('',#123,1.);
|
||||||
|
#123 = DIRECTION('',(1.,0.));
|
||||||
|
#124 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2)
|
||||||
|
PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE',''
|
||||||
|
) );
|
||||||
|
#125 = PCURVE('',#126,#131);
|
||||||
|
#126 = PLANE('',#127);
|
||||||
|
#127 = AXIS2_PLACEMENT_3D('',#128,#129,#130);
|
||||||
|
#128 = CARTESIAN_POINT('',(40.,25.,0.));
|
||||||
|
#129 = DIRECTION('',(0.,0.,1.));
|
||||||
|
#130 = DIRECTION('',(1.,0.,-0.));
|
||||||
|
#131 = DEFINITIONAL_REPRESENTATION('',(#132),#136);
|
||||||
|
#132 = LINE('',#133,#134);
|
||||||
|
#133 = CARTESIAN_POINT('',(-40.,-25.));
|
||||||
|
#134 = VECTOR('',#135,1.);
|
||||||
|
#135 = DIRECTION('',(1.,0.));
|
||||||
|
#136 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2)
|
||||||
|
PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE',''
|
||||||
|
) );
|
||||||
|
#137 = ADVANCED_FACE('',(#138),#100,.F.);
|
||||||
|
#138 = FACE_BOUND('',#139,.F.);
|
||||||
|
#139 = EDGE_LOOP('',(#140,#141,#164,#192));
|
||||||
|
#140 = ORIENTED_EDGE('',*,*,#84,.T.);
|
||||||
|
#141 = ORIENTED_EDGE('',*,*,#142,.T.);
|
||||||
|
#142 = EDGE_CURVE('',#57,#143,#145,.T.);
|
||||||
|
#143 = VERTEX_POINT('',#144);
|
||||||
|
#144 = CARTESIAN_POINT('',(80.,50.,8.));
|
||||||
|
#145 = SURFACE_CURVE('',#146,(#150,#157),.PCURVE_S1.);
|
||||||
|
#146 = LINE('',#147,#148);
|
||||||
|
#147 = CARTESIAN_POINT('',(80.,0.,8.));
|
||||||
|
#148 = VECTOR('',#149,1.);
|
||||||
|
#149 = DIRECTION('',(0.,1.,0.));
|
||||||
|
#150 = PCURVE('',#100,#151);
|
||||||
|
#151 = DEFINITIONAL_REPRESENTATION('',(#152),#156);
|
||||||
|
#152 = LINE('',#153,#154);
|
||||||
|
#153 = CARTESIAN_POINT('',(0.,-8.));
|
||||||
|
#154 = VECTOR('',#155,1.);
|
||||||
|
#155 = DIRECTION('',(1.,0.));
|
||||||
|
#156 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2)
|
||||||
|
PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE',''
|
||||||
|
) );
|
||||||
|
#157 = PCURVE('',#72,#158);
|
||||||
|
#158 = DEFINITIONAL_REPRESENTATION('',(#159),#163);
|
||||||
|
#159 = LINE('',#160,#161);
|
||||||
|
#160 = CARTESIAN_POINT('',(40.,-25.));
|
||||||
|
#161 = VECTOR('',#162,1.);
|
||||||
|
#162 = DIRECTION('',(0.,1.));
|
||||||
|
#163 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2)
|
||||||
|
PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE',''
|
||||||
|
) );
|
||||||
|
#164 = ORIENTED_EDGE('',*,*,#165,.F.);
|
||||||
|
#165 = EDGE_CURVE('',#166,#143,#168,.T.);
|
||||||
|
#166 = VERTEX_POINT('',#167);
|
||||||
|
#167 = CARTESIAN_POINT('',(80.,50.,0.));
|
||||||
|
#168 = SURFACE_CURVE('',#169,(#173,#180),.PCURVE_S1.);
|
||||||
|
#169 = LINE('',#170,#171);
|
||||||
|
#170 = CARTESIAN_POINT('',(80.,50.,0.));
|
||||||
|
#171 = VECTOR('',#172,1.);
|
||||||
|
#172 = DIRECTION('',(0.,0.,1.));
|
||||||
|
#173 = PCURVE('',#100,#174);
|
||||||
|
#174 = DEFINITIONAL_REPRESENTATION('',(#175),#179);
|
||||||
|
#175 = LINE('',#176,#177);
|
||||||
|
#176 = CARTESIAN_POINT('',(50.,0.));
|
||||||
|
#177 = VECTOR('',#178,1.);
|
||||||
|
#178 = DIRECTION('',(0.,-1.));
|
||||||
|
#179 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2)
|
||||||
|
PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE',''
|
||||||
|
) );
|
||||||
|
#180 = PCURVE('',#181,#186);
|
||||||
|
#181 = PLANE('',#182);
|
||||||
|
#182 = AXIS2_PLACEMENT_3D('',#183,#184,#185);
|
||||||
|
#183 = CARTESIAN_POINT('',(80.,50.,0.));
|
||||||
|
#184 = DIRECTION('',(0.,-1.,0.));
|
||||||
|
#185 = DIRECTION('',(-1.,0.,0.));
|
||||||
|
#186 = DEFINITIONAL_REPRESENTATION('',(#187),#191);
|
||||||
|
#187 = LINE('',#188,#189);
|
||||||
|
#188 = CARTESIAN_POINT('',(0.,-0.));
|
||||||
|
#189 = VECTOR('',#190,1.);
|
||||||
|
#190 = DIRECTION('',(0.,-1.));
|
||||||
|
#191 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2)
|
||||||
|
PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE',''
|
||||||
|
) );
|
||||||
|
#192 = ORIENTED_EDGE('',*,*,#193,.F.);
|
||||||
|
#193 = EDGE_CURVE('',#85,#166,#194,.T.);
|
||||||
|
#194 = SURFACE_CURVE('',#195,(#199,#206),.PCURVE_S1.);
|
||||||
|
#195 = LINE('',#196,#197);
|
||||||
|
#196 = CARTESIAN_POINT('',(80.,0.,0.));
|
||||||
|
#197 = VECTOR('',#198,1.);
|
||||||
|
#198 = DIRECTION('',(0.,1.,0.));
|
||||||
|
#199 = PCURVE('',#100,#200);
|
||||||
|
#200 = DEFINITIONAL_REPRESENTATION('',(#201),#205);
|
||||||
|
#201 = LINE('',#202,#203);
|
||||||
|
#202 = CARTESIAN_POINT('',(0.,0.));
|
||||||
|
#203 = VECTOR('',#204,1.);
|
||||||
|
#204 = DIRECTION('',(1.,0.));
|
||||||
|
#205 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2)
|
||||||
|
PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE',''
|
||||||
|
) );
|
||||||
|
#206 = PCURVE('',#126,#207);
|
||||||
|
#207 = DEFINITIONAL_REPRESENTATION('',(#208),#212);
|
||||||
|
#208 = LINE('',#209,#210);
|
||||||
|
#209 = CARTESIAN_POINT('',(40.,-25.));
|
||||||
|
#210 = VECTOR('',#211,1.);
|
||||||
|
#211 = DIRECTION('',(0.,1.));
|
||||||
|
#212 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2)
|
||||||
|
PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE',''
|
||||||
|
) );
|
||||||
|
#213 = ADVANCED_FACE('',(#214),#181,.F.);
|
||||||
|
#214 = FACE_BOUND('',#215,.F.);
|
||||||
|
#215 = EDGE_LOOP('',(#216,#217,#240,#263));
|
||||||
|
#216 = ORIENTED_EDGE('',*,*,#165,.T.);
|
||||||
|
#217 = ORIENTED_EDGE('',*,*,#218,.T.);
|
||||||
|
#218 = EDGE_CURVE('',#143,#219,#221,.T.);
|
||||||
|
#219 = VERTEX_POINT('',#220);
|
||||||
|
#220 = CARTESIAN_POINT('',(0.,50.,8.));
|
||||||
|
#221 = SURFACE_CURVE('',#222,(#226,#233),.PCURVE_S1.);
|
||||||
|
#222 = LINE('',#223,#224);
|
||||||
|
#223 = CARTESIAN_POINT('',(80.,50.,8.));
|
||||||
|
#224 = VECTOR('',#225,1.);
|
||||||
|
#225 = DIRECTION('',(-1.,0.,0.));
|
||||||
|
#226 = PCURVE('',#181,#227);
|
||||||
|
#227 = DEFINITIONAL_REPRESENTATION('',(#228),#232);
|
||||||
|
#228 = LINE('',#229,#230);
|
||||||
|
#229 = CARTESIAN_POINT('',(0.,-8.));
|
||||||
|
#230 = VECTOR('',#231,1.);
|
||||||
|
#231 = DIRECTION('',(1.,0.));
|
||||||
|
#232 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2)
|
||||||
|
PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE',''
|
||||||
|
) );
|
||||||
|
#233 = PCURVE('',#72,#234);
|
||||||
|
#234 = DEFINITIONAL_REPRESENTATION('',(#235),#239);
|
||||||
|
#235 = LINE('',#236,#237);
|
||||||
|
#236 = CARTESIAN_POINT('',(40.,25.));
|
||||||
|
#237 = VECTOR('',#238,1.);
|
||||||
|
#238 = DIRECTION('',(-1.,0.));
|
||||||
|
#239 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2)
|
||||||
|
PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE',''
|
||||||
|
) );
|
||||||
|
#240 = ORIENTED_EDGE('',*,*,#241,.F.);
|
||||||
|
#241 = EDGE_CURVE('',#242,#219,#244,.T.);
|
||||||
|
#242 = VERTEX_POINT('',#243);
|
||||||
|
#243 = CARTESIAN_POINT('',(0.,50.,0.));
|
||||||
|
#244 = SURFACE_CURVE('',#245,(#249,#256),.PCURVE_S1.);
|
||||||
|
#245 = LINE('',#246,#247);
|
||||||
|
#246 = CARTESIAN_POINT('',(0.,50.,0.));
|
||||||
|
#247 = VECTOR('',#248,1.);
|
||||||
|
#248 = DIRECTION('',(0.,0.,1.));
|
||||||
|
#249 = PCURVE('',#181,#250);
|
||||||
|
#250 = DEFINITIONAL_REPRESENTATION('',(#251),#255);
|
||||||
|
#251 = LINE('',#252,#253);
|
||||||
|
#252 = CARTESIAN_POINT('',(80.,0.));
|
||||||
|
#253 = VECTOR('',#254,1.);
|
||||||
|
#254 = DIRECTION('',(0.,-1.));
|
||||||
|
#255 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2)
|
||||||
|
PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE',''
|
||||||
|
) );
|
||||||
|
#256 = PCURVE('',#44,#257);
|
||||||
|
#257 = DEFINITIONAL_REPRESENTATION('',(#258),#262);
|
||||||
|
#258 = LINE('',#259,#260);
|
||||||
|
#259 = CARTESIAN_POINT('',(0.,0.));
|
||||||
|
#260 = VECTOR('',#261,1.);
|
||||||
|
#261 = DIRECTION('',(0.,-1.));
|
||||||
|
#262 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2)
|
||||||
|
PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE',''
|
||||||
|
) );
|
||||||
|
#263 = ORIENTED_EDGE('',*,*,#264,.F.);
|
||||||
|
#264 = EDGE_CURVE('',#166,#242,#265,.T.);
|
||||||
|
#265 = SURFACE_CURVE('',#266,(#270,#277),.PCURVE_S1.);
|
||||||
|
#266 = LINE('',#267,#268);
|
||||||
|
#267 = CARTESIAN_POINT('',(80.,50.,0.));
|
||||||
|
#268 = VECTOR('',#269,1.);
|
||||||
|
#269 = DIRECTION('',(-1.,0.,0.));
|
||||||
|
#270 = PCURVE('',#181,#271);
|
||||||
|
#271 = DEFINITIONAL_REPRESENTATION('',(#272),#276);
|
||||||
|
#272 = LINE('',#273,#274);
|
||||||
|
#273 = CARTESIAN_POINT('',(0.,-0.));
|
||||||
|
#274 = VECTOR('',#275,1.);
|
||||||
|
#275 = DIRECTION('',(1.,0.));
|
||||||
|
#276 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2)
|
||||||
|
PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE',''
|
||||||
|
) );
|
||||||
|
#277 = PCURVE('',#126,#278);
|
||||||
|
#278 = DEFINITIONAL_REPRESENTATION('',(#279),#283);
|
||||||
|
#279 = LINE('',#280,#281);
|
||||||
|
#280 = CARTESIAN_POINT('',(40.,25.));
|
||||||
|
#281 = VECTOR('',#282,1.);
|
||||||
|
#282 = DIRECTION('',(-1.,0.));
|
||||||
|
#283 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2)
|
||||||
|
PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE',''
|
||||||
|
) );
|
||||||
|
#284 = ADVANCED_FACE('',(#285),#44,.F.);
|
||||||
|
#285 = FACE_BOUND('',#286,.F.);
|
||||||
|
#286 = EDGE_LOOP('',(#287,#288,#309,#310));
|
||||||
|
#287 = ORIENTED_EDGE('',*,*,#241,.T.);
|
||||||
|
#288 = ORIENTED_EDGE('',*,*,#289,.T.);
|
||||||
|
#289 = EDGE_CURVE('',#219,#24,#290,.T.);
|
||||||
|
#290 = SURFACE_CURVE('',#291,(#295,#302),.PCURVE_S1.);
|
||||||
|
#291 = LINE('',#292,#293);
|
||||||
|
#292 = CARTESIAN_POINT('',(0.,50.,8.));
|
||||||
|
#293 = VECTOR('',#294,1.);
|
||||||
|
#294 = DIRECTION('',(0.,-1.,0.));
|
||||||
|
#295 = PCURVE('',#44,#296);
|
||||||
|
#296 = DEFINITIONAL_REPRESENTATION('',(#297),#301);
|
||||||
|
#297 = LINE('',#298,#299);
|
||||||
|
#298 = CARTESIAN_POINT('',(0.,-8.));
|
||||||
|
#299 = VECTOR('',#300,1.);
|
||||||
|
#300 = DIRECTION('',(1.,0.));
|
||||||
|
#301 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2)
|
||||||
|
PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE',''
|
||||||
|
) );
|
||||||
|
#302 = PCURVE('',#72,#303);
|
||||||
|
#303 = DEFINITIONAL_REPRESENTATION('',(#304),#308);
|
||||||
|
#304 = LINE('',#305,#306);
|
||||||
|
#305 = CARTESIAN_POINT('',(-40.,25.));
|
||||||
|
#306 = VECTOR('',#307,1.);
|
||||||
|
#307 = DIRECTION('',(0.,-1.));
|
||||||
|
#308 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2)
|
||||||
|
PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE',''
|
||||||
|
) );
|
||||||
|
#309 = ORIENTED_EDGE('',*,*,#21,.F.);
|
||||||
|
#310 = ORIENTED_EDGE('',*,*,#311,.F.);
|
||||||
|
#311 = EDGE_CURVE('',#242,#22,#312,.T.);
|
||||||
|
#312 = SURFACE_CURVE('',#313,(#317,#324),.PCURVE_S1.);
|
||||||
|
#313 = LINE('',#314,#315);
|
||||||
|
#314 = CARTESIAN_POINT('',(0.,50.,0.));
|
||||||
|
#315 = VECTOR('',#316,1.);
|
||||||
|
#316 = DIRECTION('',(0.,-1.,0.));
|
||||||
|
#317 = PCURVE('',#44,#318);
|
||||||
|
#318 = DEFINITIONAL_REPRESENTATION('',(#319),#323);
|
||||||
|
#319 = LINE('',#320,#321);
|
||||||
|
#320 = CARTESIAN_POINT('',(0.,0.));
|
||||||
|
#321 = VECTOR('',#322,1.);
|
||||||
|
#322 = DIRECTION('',(1.,0.));
|
||||||
|
#323 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2)
|
||||||
|
PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE',''
|
||||||
|
) );
|
||||||
|
#324 = PCURVE('',#126,#325);
|
||||||
|
#325 = DEFINITIONAL_REPRESENTATION('',(#326),#330);
|
||||||
|
#326 = LINE('',#327,#328);
|
||||||
|
#327 = CARTESIAN_POINT('',(-40.,25.));
|
||||||
|
#328 = VECTOR('',#329,1.);
|
||||||
|
#329 = DIRECTION('',(0.,-1.));
|
||||||
|
#330 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2)
|
||||||
|
PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE',''
|
||||||
|
) );
|
||||||
|
#331 = ADVANCED_FACE('',(#332),#126,.F.);
|
||||||
|
#332 = FACE_BOUND('',#333,.F.);
|
||||||
|
#333 = EDGE_LOOP('',(#334,#335,#336,#337));
|
||||||
|
#334 = ORIENTED_EDGE('',*,*,#112,.T.);
|
||||||
|
#335 = ORIENTED_EDGE('',*,*,#193,.T.);
|
||||||
|
#336 = ORIENTED_EDGE('',*,*,#264,.T.);
|
||||||
|
#337 = ORIENTED_EDGE('',*,*,#311,.T.);
|
||||||
|
#338 = ADVANCED_FACE('',(#339),#72,.T.);
|
||||||
|
#339 = FACE_BOUND('',#340,.T.);
|
||||||
|
#340 = EDGE_LOOP('',(#341,#342,#343,#344));
|
||||||
|
#341 = ORIENTED_EDGE('',*,*,#56,.T.);
|
||||||
|
#342 = ORIENTED_EDGE('',*,*,#142,.T.);
|
||||||
|
#343 = ORIENTED_EDGE('',*,*,#218,.T.);
|
||||||
|
#344 = ORIENTED_EDGE('',*,*,#289,.T.);
|
||||||
|
#345 = ( GEOMETRIC_REPRESENTATION_CONTEXT(3)
|
||||||
|
GLOBAL_UNCERTAINTY_ASSIGNED_CONTEXT((#349)) GLOBAL_UNIT_ASSIGNED_CONTEXT
|
||||||
|
((#346,#347,#348)) REPRESENTATION_CONTEXT('Context #1',
|
||||||
|
'3D Context with UNIT and UNCERTAINTY') );
|
||||||
|
#346 = ( LENGTH_UNIT() NAMED_UNIT(*) SI_UNIT(.MILLI.,.METRE.) );
|
||||||
|
#347 = ( NAMED_UNIT(*) PLANE_ANGLE_UNIT() SI_UNIT($,.RADIAN.) );
|
||||||
|
#348 = ( NAMED_UNIT(*) SI_UNIT($,.STERADIAN.) SOLID_ANGLE_UNIT() );
|
||||||
|
#349 = UNCERTAINTY_MEASURE_WITH_UNIT(LENGTH_MEASURE(1.E-07),#346,
|
||||||
|
'distance_accuracy_value','confusion accuracy');
|
||||||
|
#350 = PRODUCT_RELATED_PRODUCT_CATEGORY('part',$,(#7));
|
||||||
|
ENDSEC;
|
||||||
|
END-ISO-10303-21;
|
||||||
@@ -0,0 +1,808 @@
|
|||||||
|
{
|
||||||
|
"schema_version": "cad.topology.v1",
|
||||||
|
"task_id": "cad_a9dc4e5dbc1a",
|
||||||
|
"revision_id": "candidate_0754a6ee423cfb74",
|
||||||
|
"snapshot_id": "cad_a9dc4e5dbc1a/candidate_0754a6ee423cfb74",
|
||||||
|
"body_id": "body:feature_001",
|
||||||
|
"records": [
|
||||||
|
{
|
||||||
|
"record_id": "body:feature_001:face:0",
|
||||||
|
"kind": "face",
|
||||||
|
"feature_id": "feature_001",
|
||||||
|
"body_id": "body:feature_001",
|
||||||
|
"owner_feature_ids": [
|
||||||
|
"feature_001"
|
||||||
|
],
|
||||||
|
"geometry": {
|
||||||
|
"bbox_mm": [
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
80.0,
|
||||||
|
0.0,
|
||||||
|
8.0
|
||||||
|
],
|
||||||
|
"center_mm": [
|
||||||
|
40.0,
|
||||||
|
0.0,
|
||||||
|
4.0
|
||||||
|
],
|
||||||
|
"normal": [
|
||||||
|
0.0,
|
||||||
|
-1.0,
|
||||||
|
-0.0
|
||||||
|
],
|
||||||
|
"area_mm2": 639.9999999999999,
|
||||||
|
"surface_type": "plane",
|
||||||
|
"adjacency_signature": [
|
||||||
|
"line:8.000000:2",
|
||||||
|
"line:8.000000:2",
|
||||||
|
"line:80.000000:2",
|
||||||
|
"line:80.000000:2"
|
||||||
|
],
|
||||||
|
"plane_normal": [
|
||||||
|
-0.0,
|
||||||
|
1.0,
|
||||||
|
0.0
|
||||||
|
],
|
||||||
|
"plane_offset_mm": -0.0
|
||||||
|
},
|
||||||
|
"executable": true,
|
||||||
|
"synthetic": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"record_id": "body:feature_001:face:1",
|
||||||
|
"kind": "face",
|
||||||
|
"feature_id": "feature_001",
|
||||||
|
"body_id": "body:feature_001",
|
||||||
|
"owner_feature_ids": [
|
||||||
|
"feature_001"
|
||||||
|
],
|
||||||
|
"geometry": {
|
||||||
|
"bbox_mm": [
|
||||||
|
80.0,
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
80.0,
|
||||||
|
50.0,
|
||||||
|
8.0
|
||||||
|
],
|
||||||
|
"center_mm": [
|
||||||
|
80.0,
|
||||||
|
25.0,
|
||||||
|
4.0
|
||||||
|
],
|
||||||
|
"normal": [
|
||||||
|
1.0,
|
||||||
|
-0.0,
|
||||||
|
-0.0
|
||||||
|
],
|
||||||
|
"area_mm2": 400.0,
|
||||||
|
"surface_type": "plane",
|
||||||
|
"adjacency_signature": [
|
||||||
|
"line:50.000000:2",
|
||||||
|
"line:50.000000:2",
|
||||||
|
"line:8.000000:2",
|
||||||
|
"line:8.000000:2"
|
||||||
|
],
|
||||||
|
"plane_normal": [
|
||||||
|
1.0,
|
||||||
|
-0.0,
|
||||||
|
-0.0
|
||||||
|
],
|
||||||
|
"plane_offset_mm": 80.0
|
||||||
|
},
|
||||||
|
"executable": true,
|
||||||
|
"synthetic": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"record_id": "body:feature_001:face:2",
|
||||||
|
"kind": "face",
|
||||||
|
"feature_id": "feature_001",
|
||||||
|
"body_id": "body:feature_001",
|
||||||
|
"owner_feature_ids": [
|
||||||
|
"feature_001"
|
||||||
|
],
|
||||||
|
"geometry": {
|
||||||
|
"bbox_mm": [
|
||||||
|
0.0,
|
||||||
|
50.0,
|
||||||
|
0.0,
|
||||||
|
80.0,
|
||||||
|
50.0,
|
||||||
|
8.0
|
||||||
|
],
|
||||||
|
"center_mm": [
|
||||||
|
40.0,
|
||||||
|
50.0,
|
||||||
|
4.0
|
||||||
|
],
|
||||||
|
"normal": [
|
||||||
|
-0.0,
|
||||||
|
1.0,
|
||||||
|
-0.0
|
||||||
|
],
|
||||||
|
"area_mm2": 639.9999999999999,
|
||||||
|
"surface_type": "plane",
|
||||||
|
"adjacency_signature": [
|
||||||
|
"line:8.000000:2",
|
||||||
|
"line:8.000000:2",
|
||||||
|
"line:80.000000:2",
|
||||||
|
"line:80.000000:2"
|
||||||
|
],
|
||||||
|
"plane_normal": [
|
||||||
|
-0.0,
|
||||||
|
1.0,
|
||||||
|
-0.0
|
||||||
|
],
|
||||||
|
"plane_offset_mm": 50.0
|
||||||
|
},
|
||||||
|
"executable": true,
|
||||||
|
"synthetic": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"record_id": "body:feature_001:face:3",
|
||||||
|
"kind": "face",
|
||||||
|
"feature_id": "feature_001",
|
||||||
|
"body_id": "body:feature_001",
|
||||||
|
"owner_feature_ids": [
|
||||||
|
"feature_001"
|
||||||
|
],
|
||||||
|
"geometry": {
|
||||||
|
"bbox_mm": [
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
50.0,
|
||||||
|
8.0
|
||||||
|
],
|
||||||
|
"center_mm": [
|
||||||
|
0.0,
|
||||||
|
25.0,
|
||||||
|
4.0
|
||||||
|
],
|
||||||
|
"normal": [
|
||||||
|
-1.0,
|
||||||
|
-0.0,
|
||||||
|
-0.0
|
||||||
|
],
|
||||||
|
"area_mm2": 400.0,
|
||||||
|
"surface_type": "plane",
|
||||||
|
"adjacency_signature": [
|
||||||
|
"line:50.000000:2",
|
||||||
|
"line:50.000000:2",
|
||||||
|
"line:8.000000:2",
|
||||||
|
"line:8.000000:2"
|
||||||
|
],
|
||||||
|
"plane_normal": [
|
||||||
|
1.0,
|
||||||
|
0.0,
|
||||||
|
0.0
|
||||||
|
],
|
||||||
|
"plane_offset_mm": -0.0
|
||||||
|
},
|
||||||
|
"executable": true,
|
||||||
|
"synthetic": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"record_id": "body:feature_001:face:4",
|
||||||
|
"kind": "face",
|
||||||
|
"feature_id": "feature_001",
|
||||||
|
"body_id": "body:feature_001",
|
||||||
|
"owner_feature_ids": [
|
||||||
|
"feature_001"
|
||||||
|
],
|
||||||
|
"geometry": {
|
||||||
|
"bbox_mm": [
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
80.0,
|
||||||
|
50.0,
|
||||||
|
0.0
|
||||||
|
],
|
||||||
|
"center_mm": [
|
||||||
|
40.0,
|
||||||
|
25.0,
|
||||||
|
0.0
|
||||||
|
],
|
||||||
|
"normal": [
|
||||||
|
-0.0,
|
||||||
|
-0.0,
|
||||||
|
-1.0
|
||||||
|
],
|
||||||
|
"area_mm2": 3999.999999999999,
|
||||||
|
"surface_type": "plane",
|
||||||
|
"adjacency_signature": [
|
||||||
|
"line:50.000000:2",
|
||||||
|
"line:50.000000:2",
|
||||||
|
"line:80.000000:2",
|
||||||
|
"line:80.000000:2"
|
||||||
|
],
|
||||||
|
"plane_normal": [
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
1.0
|
||||||
|
],
|
||||||
|
"plane_offset_mm": -0.0
|
||||||
|
},
|
||||||
|
"executable": true,
|
||||||
|
"synthetic": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"record_id": "body:feature_001:face:5",
|
||||||
|
"kind": "face",
|
||||||
|
"feature_id": "feature_001",
|
||||||
|
"body_id": "body:feature_001",
|
||||||
|
"owner_feature_ids": [
|
||||||
|
"feature_001"
|
||||||
|
],
|
||||||
|
"geometry": {
|
||||||
|
"bbox_mm": [
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
8.0,
|
||||||
|
80.0,
|
||||||
|
50.0,
|
||||||
|
8.0
|
||||||
|
],
|
||||||
|
"center_mm": [
|
||||||
|
40.0,
|
||||||
|
25.0,
|
||||||
|
8.0
|
||||||
|
],
|
||||||
|
"normal": [
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
1.0
|
||||||
|
],
|
||||||
|
"area_mm2": 3999.999999999999,
|
||||||
|
"surface_type": "plane",
|
||||||
|
"adjacency_signature": [
|
||||||
|
"line:50.000000:2",
|
||||||
|
"line:50.000000:2",
|
||||||
|
"line:80.000000:2",
|
||||||
|
"line:80.000000:2"
|
||||||
|
],
|
||||||
|
"plane_normal": [
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
1.0
|
||||||
|
],
|
||||||
|
"plane_offset_mm": 8.0
|
||||||
|
},
|
||||||
|
"executable": true,
|
||||||
|
"synthetic": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"record_id": "body:feature_001:edge:0",
|
||||||
|
"kind": "edge",
|
||||||
|
"feature_id": "feature_001",
|
||||||
|
"body_id": "body:feature_001",
|
||||||
|
"owner_feature_ids": [
|
||||||
|
"feature_001"
|
||||||
|
],
|
||||||
|
"geometry": {
|
||||||
|
"bbox_mm": [
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
8.0
|
||||||
|
],
|
||||||
|
"center_mm": [
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
4.0
|
||||||
|
],
|
||||||
|
"length_mm": 8.0,
|
||||||
|
"curve_type": "line",
|
||||||
|
"adjacent_face_count": 2,
|
||||||
|
"start_mm": [
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
0.0
|
||||||
|
],
|
||||||
|
"end_mm": [
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
8.0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"executable": true,
|
||||||
|
"synthetic": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"record_id": "body:feature_001:edge:1",
|
||||||
|
"kind": "edge",
|
||||||
|
"feature_id": "feature_001",
|
||||||
|
"body_id": "body:feature_001",
|
||||||
|
"owner_feature_ids": [
|
||||||
|
"feature_001"
|
||||||
|
],
|
||||||
|
"geometry": {
|
||||||
|
"bbox_mm": [
|
||||||
|
80.0,
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
80.0,
|
||||||
|
0.0,
|
||||||
|
8.0
|
||||||
|
],
|
||||||
|
"center_mm": [
|
||||||
|
80.0,
|
||||||
|
0.0,
|
||||||
|
4.0
|
||||||
|
],
|
||||||
|
"length_mm": 8.0,
|
||||||
|
"curve_type": "line",
|
||||||
|
"adjacent_face_count": 2,
|
||||||
|
"start_mm": [
|
||||||
|
80.0,
|
||||||
|
0.0,
|
||||||
|
0.0
|
||||||
|
],
|
||||||
|
"end_mm": [
|
||||||
|
80.0,
|
||||||
|
0.0,
|
||||||
|
8.0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"executable": true,
|
||||||
|
"synthetic": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"record_id": "body:feature_001:edge:2",
|
||||||
|
"kind": "edge",
|
||||||
|
"feature_id": "feature_001",
|
||||||
|
"body_id": "body:feature_001",
|
||||||
|
"owner_feature_ids": [
|
||||||
|
"feature_001"
|
||||||
|
],
|
||||||
|
"geometry": {
|
||||||
|
"bbox_mm": [
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
80.0,
|
||||||
|
0.0,
|
||||||
|
0.0
|
||||||
|
],
|
||||||
|
"center_mm": [
|
||||||
|
40.0,
|
||||||
|
0.0,
|
||||||
|
0.0
|
||||||
|
],
|
||||||
|
"length_mm": 80.0,
|
||||||
|
"curve_type": "line",
|
||||||
|
"adjacent_face_count": 2,
|
||||||
|
"start_mm": [
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
0.0
|
||||||
|
],
|
||||||
|
"end_mm": [
|
||||||
|
80.0,
|
||||||
|
0.0,
|
||||||
|
0.0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"executable": true,
|
||||||
|
"synthetic": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"record_id": "body:feature_001:edge:3",
|
||||||
|
"kind": "edge",
|
||||||
|
"feature_id": "feature_001",
|
||||||
|
"body_id": "body:feature_001",
|
||||||
|
"owner_feature_ids": [
|
||||||
|
"feature_001"
|
||||||
|
],
|
||||||
|
"geometry": {
|
||||||
|
"bbox_mm": [
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
8.0,
|
||||||
|
80.0,
|
||||||
|
0.0,
|
||||||
|
8.0
|
||||||
|
],
|
||||||
|
"center_mm": [
|
||||||
|
40.0,
|
||||||
|
0.0,
|
||||||
|
8.0
|
||||||
|
],
|
||||||
|
"length_mm": 80.0,
|
||||||
|
"curve_type": "line",
|
||||||
|
"adjacent_face_count": 2,
|
||||||
|
"start_mm": [
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
8.0
|
||||||
|
],
|
||||||
|
"end_mm": [
|
||||||
|
80.0,
|
||||||
|
0.0,
|
||||||
|
8.0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"executable": true,
|
||||||
|
"synthetic": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"record_id": "body:feature_001:edge:4",
|
||||||
|
"kind": "edge",
|
||||||
|
"feature_id": "feature_001",
|
||||||
|
"body_id": "body:feature_001",
|
||||||
|
"owner_feature_ids": [
|
||||||
|
"feature_001"
|
||||||
|
],
|
||||||
|
"geometry": {
|
||||||
|
"bbox_mm": [
|
||||||
|
80.0,
|
||||||
|
50.0,
|
||||||
|
0.0,
|
||||||
|
80.0,
|
||||||
|
50.0,
|
||||||
|
8.0
|
||||||
|
],
|
||||||
|
"center_mm": [
|
||||||
|
80.0,
|
||||||
|
50.0,
|
||||||
|
4.0
|
||||||
|
],
|
||||||
|
"length_mm": 8.0,
|
||||||
|
"curve_type": "line",
|
||||||
|
"adjacent_face_count": 2,
|
||||||
|
"start_mm": [
|
||||||
|
80.0,
|
||||||
|
50.0,
|
||||||
|
0.0
|
||||||
|
],
|
||||||
|
"end_mm": [
|
||||||
|
80.0,
|
||||||
|
50.0,
|
||||||
|
8.0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"executable": true,
|
||||||
|
"synthetic": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"record_id": "body:feature_001:edge:5",
|
||||||
|
"kind": "edge",
|
||||||
|
"feature_id": "feature_001",
|
||||||
|
"body_id": "body:feature_001",
|
||||||
|
"owner_feature_ids": [
|
||||||
|
"feature_001"
|
||||||
|
],
|
||||||
|
"geometry": {
|
||||||
|
"bbox_mm": [
|
||||||
|
80.0,
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
80.0,
|
||||||
|
50.0,
|
||||||
|
0.0
|
||||||
|
],
|
||||||
|
"center_mm": [
|
||||||
|
80.0,
|
||||||
|
25.0,
|
||||||
|
0.0
|
||||||
|
],
|
||||||
|
"length_mm": 50.0,
|
||||||
|
"curve_type": "line",
|
||||||
|
"adjacent_face_count": 2,
|
||||||
|
"start_mm": [
|
||||||
|
80.0,
|
||||||
|
0.0,
|
||||||
|
0.0
|
||||||
|
],
|
||||||
|
"end_mm": [
|
||||||
|
80.0,
|
||||||
|
50.0,
|
||||||
|
0.0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"executable": true,
|
||||||
|
"synthetic": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"record_id": "body:feature_001:edge:6",
|
||||||
|
"kind": "edge",
|
||||||
|
"feature_id": "feature_001",
|
||||||
|
"body_id": "body:feature_001",
|
||||||
|
"owner_feature_ids": [
|
||||||
|
"feature_001"
|
||||||
|
],
|
||||||
|
"geometry": {
|
||||||
|
"bbox_mm": [
|
||||||
|
80.0,
|
||||||
|
0.0,
|
||||||
|
8.0,
|
||||||
|
80.0,
|
||||||
|
50.0,
|
||||||
|
8.0
|
||||||
|
],
|
||||||
|
"center_mm": [
|
||||||
|
80.0,
|
||||||
|
25.0,
|
||||||
|
8.0
|
||||||
|
],
|
||||||
|
"length_mm": 50.0,
|
||||||
|
"curve_type": "line",
|
||||||
|
"adjacent_face_count": 2,
|
||||||
|
"start_mm": [
|
||||||
|
80.0,
|
||||||
|
0.0,
|
||||||
|
8.0
|
||||||
|
],
|
||||||
|
"end_mm": [
|
||||||
|
80.0,
|
||||||
|
50.0,
|
||||||
|
8.0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"executable": true,
|
||||||
|
"synthetic": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"record_id": "body:feature_001:edge:7",
|
||||||
|
"kind": "edge",
|
||||||
|
"feature_id": "feature_001",
|
||||||
|
"body_id": "body:feature_001",
|
||||||
|
"owner_feature_ids": [
|
||||||
|
"feature_001"
|
||||||
|
],
|
||||||
|
"geometry": {
|
||||||
|
"bbox_mm": [
|
||||||
|
0.0,
|
||||||
|
50.0,
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
50.0,
|
||||||
|
8.0
|
||||||
|
],
|
||||||
|
"center_mm": [
|
||||||
|
0.0,
|
||||||
|
50.0,
|
||||||
|
4.0
|
||||||
|
],
|
||||||
|
"length_mm": 8.0,
|
||||||
|
"curve_type": "line",
|
||||||
|
"adjacent_face_count": 2,
|
||||||
|
"start_mm": [
|
||||||
|
0.0,
|
||||||
|
50.0,
|
||||||
|
0.0
|
||||||
|
],
|
||||||
|
"end_mm": [
|
||||||
|
0.0,
|
||||||
|
50.0,
|
||||||
|
8.0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"executable": true,
|
||||||
|
"synthetic": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"record_id": "body:feature_001:edge:8",
|
||||||
|
"kind": "edge",
|
||||||
|
"feature_id": "feature_001",
|
||||||
|
"body_id": "body:feature_001",
|
||||||
|
"owner_feature_ids": [
|
||||||
|
"feature_001"
|
||||||
|
],
|
||||||
|
"geometry": {
|
||||||
|
"bbox_mm": [
|
||||||
|
0.0,
|
||||||
|
50.0,
|
||||||
|
0.0,
|
||||||
|
80.0,
|
||||||
|
50.0,
|
||||||
|
0.0
|
||||||
|
],
|
||||||
|
"center_mm": [
|
||||||
|
40.0,
|
||||||
|
50.0,
|
||||||
|
0.0
|
||||||
|
],
|
||||||
|
"length_mm": 80.0,
|
||||||
|
"curve_type": "line",
|
||||||
|
"adjacent_face_count": 2,
|
||||||
|
"start_mm": [
|
||||||
|
80.0,
|
||||||
|
50.0,
|
||||||
|
0.0
|
||||||
|
],
|
||||||
|
"end_mm": [
|
||||||
|
0.0,
|
||||||
|
50.0,
|
||||||
|
0.0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"executable": true,
|
||||||
|
"synthetic": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"record_id": "body:feature_001:edge:9",
|
||||||
|
"kind": "edge",
|
||||||
|
"feature_id": "feature_001",
|
||||||
|
"body_id": "body:feature_001",
|
||||||
|
"owner_feature_ids": [
|
||||||
|
"feature_001"
|
||||||
|
],
|
||||||
|
"geometry": {
|
||||||
|
"bbox_mm": [
|
||||||
|
0.0,
|
||||||
|
50.0,
|
||||||
|
8.0,
|
||||||
|
80.0,
|
||||||
|
50.0,
|
||||||
|
8.0
|
||||||
|
],
|
||||||
|
"center_mm": [
|
||||||
|
40.0,
|
||||||
|
50.0,
|
||||||
|
8.0
|
||||||
|
],
|
||||||
|
"length_mm": 80.0,
|
||||||
|
"curve_type": "line",
|
||||||
|
"adjacent_face_count": 2,
|
||||||
|
"start_mm": [
|
||||||
|
80.0,
|
||||||
|
50.0,
|
||||||
|
8.0
|
||||||
|
],
|
||||||
|
"end_mm": [
|
||||||
|
0.0,
|
||||||
|
50.0,
|
||||||
|
8.0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"executable": true,
|
||||||
|
"synthetic": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"record_id": "body:feature_001:edge:10",
|
||||||
|
"kind": "edge",
|
||||||
|
"feature_id": "feature_001",
|
||||||
|
"body_id": "body:feature_001",
|
||||||
|
"owner_feature_ids": [
|
||||||
|
"feature_001"
|
||||||
|
],
|
||||||
|
"geometry": {
|
||||||
|
"bbox_mm": [
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
50.0,
|
||||||
|
0.0
|
||||||
|
],
|
||||||
|
"center_mm": [
|
||||||
|
0.0,
|
||||||
|
25.0,
|
||||||
|
0.0
|
||||||
|
],
|
||||||
|
"length_mm": 50.0,
|
||||||
|
"curve_type": "line",
|
||||||
|
"adjacent_face_count": 2,
|
||||||
|
"start_mm": [
|
||||||
|
0.0,
|
||||||
|
50.0,
|
||||||
|
0.0
|
||||||
|
],
|
||||||
|
"end_mm": [
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
0.0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"executable": true,
|
||||||
|
"synthetic": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"record_id": "body:feature_001:edge:11",
|
||||||
|
"kind": "edge",
|
||||||
|
"feature_id": "feature_001",
|
||||||
|
"body_id": "body:feature_001",
|
||||||
|
"owner_feature_ids": [
|
||||||
|
"feature_001"
|
||||||
|
],
|
||||||
|
"geometry": {
|
||||||
|
"bbox_mm": [
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
8.0,
|
||||||
|
0.0,
|
||||||
|
50.0,
|
||||||
|
8.0
|
||||||
|
],
|
||||||
|
"center_mm": [
|
||||||
|
0.0,
|
||||||
|
25.0,
|
||||||
|
8.0
|
||||||
|
],
|
||||||
|
"length_mm": 50.0,
|
||||||
|
"curve_type": "line",
|
||||||
|
"adjacent_face_count": 2,
|
||||||
|
"start_mm": [
|
||||||
|
0.0,
|
||||||
|
50.0,
|
||||||
|
8.0
|
||||||
|
],
|
||||||
|
"end_mm": [
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
8.0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"executable": true,
|
||||||
|
"synthetic": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"record_id": "body:feature_001:vertex:0",
|
||||||
|
"kind": "vertex",
|
||||||
|
"feature_id": "feature_001",
|
||||||
|
"body_id": "body:feature_001",
|
||||||
|
"owner_feature_ids": [
|
||||||
|
"feature_001"
|
||||||
|
],
|
||||||
|
"geometry": {
|
||||||
|
"center_mm": [
|
||||||
|
0.0,
|
||||||
|
50.0,
|
||||||
|
8.0
|
||||||
|
],
|
||||||
|
"incident_edge_count": 3
|
||||||
|
},
|
||||||
|
"executable": true,
|
||||||
|
"synthetic": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"record_id": "body:feature_001:vertex:1",
|
||||||
|
"kind": "vertex",
|
||||||
|
"feature_id": "feature_001",
|
||||||
|
"body_id": "body:feature_001",
|
||||||
|
"owner_feature_ids": [
|
||||||
|
"feature_001"
|
||||||
|
],
|
||||||
|
"geometry": {
|
||||||
|
"center_mm": [
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
8.0
|
||||||
|
],
|
||||||
|
"incident_edge_count": 3
|
||||||
|
},
|
||||||
|
"executable": true,
|
||||||
|
"synthetic": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"record_id": "body:feature_001",
|
||||||
|
"kind": "body",
|
||||||
|
"feature_id": "feature_001",
|
||||||
|
"body_id": "body:feature_001",
|
||||||
|
"owner_feature_ids": [
|
||||||
|
"feature_001"
|
||||||
|
],
|
||||||
|
"geometry": {
|
||||||
|
"bbox_mm": [
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
80.0,
|
||||||
|
50.0,
|
||||||
|
8.0
|
||||||
|
],
|
||||||
|
"volume_mm3": 31999.999999999996,
|
||||||
|
"solid_count": 1
|
||||||
|
},
|
||||||
|
"executable": true,
|
||||||
|
"synthetic": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 6.0 KiB |
|
After Width: | Height: | Size: 6.0 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 4.6 KiB |