Files
cdsl-cad/backend/app/cad_agent/application/authoring_contract.py
T
2026-09-16 10:22:36 +08:00

205 lines
7.9 KiB
Python

"""Strict model-facing Authoring CDSL contract.
This is intentionally separate from the runtime CDSL: model output contains
only document-local names and declarative references. Runtime identities are
allocated by :mod:`authoring_compiler`.
"""
from __future__ import annotations
import math
import re
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
_NAME = r"^[a-z][a-z0-9_]{0,63}$"
_FORBIDDEN = {
"id",
"task_id", "revision_id", "candidate_id", "action_id", "requirement_id",
"claim_id", "evidence_id", "feature_id", "sketch_id", "body_id",
"stable_id", "snapshot_id", "owner_feature_id", "working_head",
"selector_token", "selector_tokens", "selector token", "selector-token", "selectorToken",
"host_face", "mirror_plane",
}
def validation_error_code(error: Exception) -> str:
"""Map strict model validation failures to a stable public diagnostic."""
return "AUTHOR_FORBIDDEN_FIELD" if "AUTHOR_FORBIDDEN_FIELD:" in str(error) else "AUTHOR_SCHEMA_INVALID"
class AuthorModel(BaseModel):
model_config = ConfigDict(extra="forbid", strict=True)
@model_validator(mode="before")
@classmethod
def reject_internal_fields(cls, value: Any) -> Any:
if isinstance(value, dict):
found = sorted(
key for key in value
if isinstance(key, str)
and (key in _FORBIDDEN or key.lower().replace("-", "_").replace(" ", "_") in _FORBIDDEN)
)
if found:
raise ValueError(f"AUTHOR_FORBIDDEN_FIELD: {found[0]}")
for nested in value.values():
cls.reject_internal_fields(nested)
return value
if isinstance(value, list):
for item in value:
cls.reject_internal_fields(item)
return value
class AuthorWorkplane(AuthorModel):
"""A fully explicit local sketch frame in world millimetres."""
origin_mm: list[float] = Field(min_length=3, max_length=3)
x_dir: list[float] = Field(min_length=3, max_length=3)
normal: list[float] = Field(min_length=3, max_length=3)
class AuthorCircleProfile(AuthorModel):
"""A declarative circle expressed with the user-facing diameter."""
type: Literal["circle"]
diameter_mm: float = Field(gt=0)
center_mm: list[float] = Field(default_factory=lambda: [0.0, 0.0], min_length=2, max_length=2)
class AuthorPolygonProfile(AuthorModel):
"""A closed polygon in the local sketch workplane."""
type: Literal["polygon"]
vertices: list[list[float]] = Field(min_length=3)
@field_validator("vertices")
@classmethod
def require_planar_points(cls, value: list[list[float]]) -> list[list[float]]:
if any(len(point) != 2 for point in value):
raise ValueError("polygon vertices must have exactly two coordinates")
return value
class AuthorSketch(AuthorModel):
"""The only authoring sketch form currently accepted by the compiler."""
workplane: AuthorWorkplane
profile: AuthorCircleProfile | AuthorPolygonProfile
class SelectorIntent(AuthorModel):
"""A local feature-output reference, never a Runtime selector token."""
kind: Literal["face", "edge", "axis", "plane", "vertex", "body"]
source: str = Field(
min_length=3,
max_length=160,
description="A local feature output in the form <feature_name>.<output_role>.",
)
match: Literal["unique", "all"] = "unique"
@field_validator("source")
@classmethod
def require_feature_output_reference(cls, value: str) -> str:
feature, separator, role = value.partition(".")
if not separator or not re.fullmatch(_NAME, feature) or not re.fullmatch(r"[a-z][a-z0-9_.-]{0,80}", role):
raise ValueError("selector source must be <feature_name>.<output_role>")
return value
class FeatureIntent(AuthorModel):
"""Feature-level semantic annotation for training data.
Mirrors ``$defs/featureIntent`` in ``cdsl_schema.json``: purely
descriptive, never read by the compiler for geometry decisions.
"""
label: str | None = Field(default=None, pattern=r"^[a-z][a-z0-9_]{2,63}$")
summary: str = Field(min_length=1, max_length=80)
why: str | None = Field(default=None, min_length=1, max_length=400)
ties_to_requirement: str | None = Field(default=None, pattern=r"^[A-Za-z0-9_.:-]{1,80}$")
provenance: Literal["authored", "annotated", "imported"]
class DocumentMeta(AuthorModel):
"""Document-level semantic annotation (part description and function)."""
description: str | None = Field(default=None, min_length=1, max_length=60)
function: str | None = Field(default=None, min_length=1, max_length=200)
@model_validator(mode="after")
def require_at_least_one(self) -> "DocumentMeta":
if self.description is None and self.function is None:
raise ValueError("meta requires at least one of description or function")
return self
class AuthorFeature(AuthorModel):
name: str = Field(pattern=_NAME)
operation: str = Field(pattern=r"^[a-z][a-z0-9_]{0,80}$")
params: dict[str, Any] = Field(
default_factory=dict,
description="Only parameters from this feature operation's supplied params_schema.",
)
depends_on: list[str] = Field(default_factory=list, max_length=32)
selectors: list[SelectorIntent] = Field(default_factory=list, max_length=32)
sketch: AuthorSketch | None = Field(
default=None,
description="For sketch operations: exactly {workplane, profile}. Circle profiles use diameter_mm and center_mm.",
)
intent: FeatureIntent | None = Field(
default=None,
description="Optional semantic annotation for training; never consumed by geometry.",
)
class AuthorBody(AuthorModel):
name: str = Field(pattern=_NAME)
features: list[AuthorFeature] = Field(min_length=1, max_length=256)
class AuthoringDocument(AuthorModel):
schema_version: str = Field(default="cad.author.v1", pattern=r"^cad\.author\.v1$")
units: str = Field(default="mm", pattern=r"^mm$")
coordinate_system: str = Field(default="right_handed", pattern=r"^[a-z][a-z0-9_-]{0,40}$")
assumptions: list[str] = Field(default_factory=list, max_length=64)
meta: DocumentMeta | None = Field(
default=None,
description="Optional document-level semantic annotation (description/function).",
)
bodies: list[AuthorBody] = Field(min_length=1, max_length=32)
acceptance_targets: list[dict[str, Any]] = Field(default_factory=list, max_length=128)
@model_validator(mode="after")
def validate_symbols(self) -> "AuthoringDocument":
validate_finite(self.model_dump(mode="python"))
bodies = [b.name for b in self.bodies]
if len(bodies) != len(set(bodies)):
raise ValueError("duplicate body name")
names: set[str] = set()
for body in self.bodies:
for feature in body.features:
if feature.name in names:
raise ValueError(f"duplicate feature name: {feature.name}")
names.add(feature.name)
for body in self.bodies:
for feature in body.features:
if len(feature.depends_on) != len(set(feature.depends_on)):
raise ValueError(f"duplicate dependency: {feature.name}")
if any(dep not in names for dep in feature.depends_on):
missing = next(dep for dep in feature.depends_on if dep not in names)
raise ValueError(f"unknown feature reference: {missing}")
return self
def validate_finite(value: Any, path: str = "$") -> None:
if isinstance(value, float) and not math.isfinite(value):
raise ValueError(f"non-finite number at {path}")
if isinstance(value, dict):
for key, item in value.items():
validate_finite(item, f"{path}.{key}")
elif isinstance(value, list):
for index, item in enumerate(value):
validate_finite(item, f"{path}[{index}]")