重构 改为标准化输出
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user