413 lines
19 KiB
Python
413 lines
19 KiB
Python
"""Canonical schemas for every v3 LLM state-changing command.
|
|
|
|
Providers may parse structured output first, but this module validates the raw
|
|
tool arguments a second time before a command reaches a handler.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from copy import deepcopy
|
|
import hashlib
|
|
import json
|
|
import math
|
|
from typing import Annotated, Any, Literal, TypeVar
|
|
|
|
from jsonschema import Draft202012Validator
|
|
from pydantic import BaseModel, ConfigDict, Field, JsonValue, RootModel, ValidationError, model_validator
|
|
|
|
from app.cad_agent.domain.errors import ErrorCode, WorkflowError
|
|
|
|
|
|
class StrictDto(BaseModel):
|
|
model_config = ConfigDict(extra="forbid", strict=True, str_strip_whitespace=True)
|
|
|
|
|
|
ShortText = Annotated[str, Field(min_length=1, max_length=360)]
|
|
Identifier = Annotated[str, Field(pattern=r"^[a-z][a-z0-9_:-]{0,95}$")]
|
|
|
|
|
|
class AcceptanceClaimInput(StrictDto):
|
|
claim_kind: Identifier
|
|
expected: dict[str, JsonValue] = Field(min_length=0, max_length=24)
|
|
|
|
|
|
class SpecRequirementInput(StrictDto):
|
|
statement: Annotated[str, Field(min_length=1, max_length=1000)]
|
|
assumptions: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=16)
|
|
acceptance_claims: list[AcceptanceClaimInput] = Field(min_length=1, max_length=16)
|
|
|
|
|
|
class MarkdownDocument(StrictDto):
|
|
"""A frozen human-readable design artifact, never an executable payload."""
|
|
markdown: Annotated[str, Field(min_length=1, max_length=16_000)]
|
|
|
|
|
|
class CompiledRequirementInput(StrictDto):
|
|
"""One verifier bundle for one server-parsed checklist item.
|
|
|
|
The checklist text, ordering, source bindings, and all identifiers are
|
|
intentionally absent: the service owns them after Markdown is frozen.
|
|
"""
|
|
assumptions: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=16)
|
|
acceptance_claims: list[AcceptanceClaimInput] = Field(min_length=1, max_length=16)
|
|
|
|
|
|
class CompiledRequirementsSpec(StrictDto):
|
|
requirements: list[CompiledRequirementInput] = Field(min_length=1, max_length=64)
|
|
|
|
|
|
# Kept only so an interrupted process with an already imported old tool schema
|
|
# fails at the workflow boundary instead of failing module import. New v3.1
|
|
# tasks never expose or accept this aggregate specification.
|
|
class RequirementsSpec(StrictDto):
|
|
outcome: Literal["ready"]
|
|
summary: Annotated[str, Field(min_length=1, max_length=2000)]
|
|
assumptions: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=32)
|
|
requirements: list[SpecRequirementInput] = Field(min_length=1, max_length=32)
|
|
|
|
|
|
class RequirementsClarification(StrictDto):
|
|
outcome: Literal["clarification"]
|
|
source_quotes: list[Annotated[str, Field(min_length=1, max_length=500)]] = Field(min_length=2, max_length=4)
|
|
question: Annotated[str, Field(min_length=1, max_length=500)]
|
|
|
|
|
|
class RequirementsAuthorOutput(RootModel[Annotated[RequirementsSpec | RequirementsClarification, Field(discriminator="outcome")]]):
|
|
pass
|
|
|
|
|
|
class EmptyCommand(StrictDto):
|
|
pass
|
|
|
|
|
|
class NextAction(StrictDto):
|
|
working_head: Annotated[str, Field(pattern=r"^[a-z0-9_:-]{5,192}$")]
|
|
intent: ShortText
|
|
# The server binds this list from every frozen checklist target. It is not
|
|
# author input, so a five-item UI-era limit must not reject a valid task.
|
|
requirement_ids: list[Identifier] = Field(min_length=1, max_length=64)
|
|
atomic_id: Identifier
|
|
expected_change: ShortText
|
|
|
|
@model_validator(mode="after")
|
|
def _requirement_ids_are_unique(self) -> "NextAction":
|
|
if len(self.requirement_ids) != len(set(self.requirement_ids)):
|
|
raise ValueError("requirement_ids must not contain duplicates")
|
|
return self
|
|
|
|
|
|
class StatelessNextAction(StrictDto):
|
|
intent: ShortText
|
|
operation: Identifier
|
|
expected_change: ShortText
|
|
|
|
|
|
class TopologyRequest(StrictDto):
|
|
working_head: Annotated[str, Field(pattern=r"^[a-z0-9_:-]{5,192}$")]
|
|
kind: Literal["face", "edge", "vertex", "plane", "axis", "body"] | None = None
|
|
limit: int = Field(default=16, ge=1, le=64)
|
|
|
|
|
|
class GeometryConclusion(StrictDto):
|
|
working_head: Annotated[str, Field(pattern=r"^[a-z0-9_:-]{5,192}$")]
|
|
evidence_refs: list[Identifier] = Field(min_length=1, max_length=16)
|
|
root_cause: Annotated[str, Field(min_length=1, max_length=360)]
|
|
decision: Literal["return_to_action_selection", "rollback"]
|
|
corrective_intent: str | None = Field(default=None, min_length=1, max_length=360)
|
|
|
|
@model_validator(mode="after")
|
|
def _evidence_refs_are_unique(self) -> "GeometryConclusion":
|
|
if len(self.evidence_refs) != len(set(self.evidence_refs)):
|
|
raise ValueError("evidence_refs must not contain duplicates")
|
|
return self
|
|
|
|
|
|
class RollbackCheckpoint(StrictDto):
|
|
working_head: Annotated[str, Field(pattern=r"^[a-z0-9_:-]{5,192}$")]
|
|
checkpoint_token: Identifier
|
|
reason: Annotated[str, Field(min_length=1, max_length=360)]
|
|
|
|
|
|
class StatelessTopologyRequest(StrictDto):
|
|
kind: Literal["face", "edge", "vertex", "plane", "axis", "body"] | None = None
|
|
limit: int = Field(default=16, ge=1, le=64)
|
|
|
|
|
|
class StatelessGeometryConclusion(StrictDto):
|
|
root_cause: Annotated[str, Field(min_length=1, max_length=360)]
|
|
decision: Literal["return_to_action_selection", "rollback"]
|
|
corrective_intent: str | None = Field(default=None, min_length=1, max_length=360)
|
|
|
|
|
|
class StatelessRollbackCheckpoint(StrictDto):
|
|
checkpoint_token: Identifier
|
|
reason: Annotated[str, Field(min_length=1, max_length=360)]
|
|
|
|
|
|
class ClaimCoverage(StrictDto):
|
|
claim_id: Identifier
|
|
status: Literal["pass", "pending", "fail", "not_applicable"]
|
|
evidence_refs: list[Identifier] = Field(default_factory=list, max_length=16)
|
|
|
|
@model_validator(mode="after")
|
|
def _evidence_refs_are_unique(self) -> "ClaimCoverage":
|
|
if len(self.evidence_refs) != len(set(self.evidence_refs)):
|
|
raise ValueError("evidence_refs must not contain duplicates")
|
|
return self
|
|
|
|
|
|
class CandidateReview(StrictDto):
|
|
candidate_id: Identifier = Field(description="Server-issued candidate ID from the review facts.")
|
|
working_head: Annotated[str, Field(pattern=r"^[a-z0-9_:-]{5,192}$", description="Current server-issued working head from the review facts.")]
|
|
verdict: Literal["accept", "reject"] = Field(description="Required independent decision. Set accept only when the supplied candidate evidence supports every covered claim; otherwise set reject.")
|
|
claim_coverage: list[ClaimCoverage] = Field(min_length=1, max_length=128, description="Required coverage decision for every claim ID in the supplied candidate facts.")
|
|
evidence: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=16)
|
|
issues: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=16)
|
|
|
|
|
|
class StatelessCandidateReview(StrictDto):
|
|
verdict: Literal["accept", "reject"]
|
|
evidence: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=16)
|
|
issues: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=16)
|
|
|
|
|
|
class VisualClaimDecision(StrictDto):
|
|
status: Literal["pass", "fail"]
|
|
evidence: Annotated[str, Field(min_length=1, max_length=360)]
|
|
|
|
|
|
class StatelessFinalReview(StrictDto):
|
|
verdict: Literal["pass", "repair"]
|
|
visual_claims: list[VisualClaimDecision] = Field(default_factory=list, max_length=128)
|
|
evidence: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=16)
|
|
issues: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=16)
|
|
|
|
|
|
class ImageMeasurement(StrictDto):
|
|
name: Annotated[str, Field(min_length=1, max_length=160)]
|
|
value: float | None = None
|
|
unit: Literal["mm", "degree", "count", "unknown"] = "unknown"
|
|
evidence: Annotated[str, Field(min_length=1, max_length=360)]
|
|
confidence: float = Field(ge=0, le=1)
|
|
|
|
|
|
class ImageObservation(StrictDto):
|
|
summary: Annotated[str, Field(min_length=1, max_length=2000)]
|
|
visible_features: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=64)
|
|
measurements: list[ImageMeasurement] = Field(default_factory=list, max_length=128)
|
|
view_directions: list[Annotated[str, Field(min_length=1, max_length=120)]] = Field(default_factory=list, max_length=16)
|
|
uncertainties: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=64)
|
|
assumptions: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=64)
|
|
|
|
|
|
class FinalReview(StrictDto):
|
|
working_head: Annotated[str, Field(pattern=r"^[a-z0-9_:-]{5,192}$", description="Current server-issued working head from the final review facts.")]
|
|
verdict: Literal["pass", "repair"] = Field(description="Required independent final decision. Set pass only when the supplied evidence supports every claim; otherwise set repair.")
|
|
claim_coverage: list[ClaimCoverage] = Field(min_length=1, max_length=128, description="Required coverage decision for every claim ID in the supplied final-review facts.")
|
|
evidence: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=16)
|
|
issues: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=16)
|
|
|
|
|
|
def requirements_spec_schema(claim_one_of: dict[str, Any]) -> dict[str, Any]:
|
|
schema = RequirementsAuthorOutput.model_json_schema()
|
|
requirement = schema.get("$defs", {}).get("SpecRequirementInput")
|
|
if isinstance(requirement, dict):
|
|
claims = requirement.get("properties", {}).get("acceptance_claims")
|
|
if isinstance(claims, dict):
|
|
claims["items"] = deepcopy(claim_one_of)
|
|
return schema
|
|
|
|
|
|
def compiled_requirements_schema(claim_one_of: dict[str, Any], target_count: int) -> dict[str, Any]:
|
|
schema = CompiledRequirementsSpec.model_json_schema()
|
|
definitions = schema.get("$defs", {})
|
|
requirement = definitions.get("CompiledRequirementInput") if isinstance(definitions, dict) else None
|
|
if isinstance(requirement, dict):
|
|
claims = requirement.get("properties", {}).get("acceptance_claims")
|
|
if isinstance(claims, dict):
|
|
claims["items"] = deepcopy(claim_one_of)
|
|
requirements = schema.get("properties", {}).get("requirements")
|
|
if isinstance(requirements, dict):
|
|
requirements["minItems"] = target_count
|
|
requirements["maxItems"] = target_count
|
|
return schema
|
|
|
|
|
|
def sanitize_compiled_requirements_arguments(raw_arguments_json: str) -> str | WorkflowError:
|
|
"""Drop harmless compiler chatter before strict requirements validation.
|
|
|
|
``compile_requirements_spec`` is a compiler stage: the service only needs
|
|
the ordered verifier bundles for the frozen checklist items. Real models
|
|
sometimes add explanatory fields such as a top-level ``assumptions`` or
|
|
per-item ``statement`` even when the dynamic tool schema forbids them. Those
|
|
fields are not executable and are not part of the frozen contract, so they
|
|
should not abort a task before modeling starts.
|
|
|
|
The verifier ``expected`` payload is intentionally not sanitized here. It
|
|
remains governed by the registry's strict per-claim schema because those
|
|
values drive deterministic validation.
|
|
"""
|
|
value = canonical_json_object(raw_arguments_json)
|
|
if isinstance(value, WorkflowError):
|
|
return value
|
|
requirements = value.get("requirements")
|
|
sanitized: dict[str, Any] = {}
|
|
if isinstance(requirements, list):
|
|
sanitized_requirements: list[Any] = []
|
|
for requirement in requirements:
|
|
if not isinstance(requirement, dict):
|
|
sanitized_requirements.append(requirement)
|
|
continue
|
|
item: dict[str, Any] = {}
|
|
if "assumptions" in requirement:
|
|
item["assumptions"] = requirement["assumptions"]
|
|
if "acceptance_claims" in requirement:
|
|
claims = requirement["acceptance_claims"]
|
|
if isinstance(claims, list):
|
|
item["acceptance_claims"] = [
|
|
{key: claim[key] for key in ("claim_kind", "expected") if isinstance(claim, dict) and key in claim}
|
|
if isinstance(claim, dict) else claim
|
|
for claim in claims
|
|
]
|
|
else:
|
|
item["acceptance_claims"] = claims
|
|
sanitized_requirements.append(item)
|
|
sanitized["requirements"] = sanitized_requirements
|
|
else:
|
|
sanitized["requirements"] = requirements
|
|
return json.dumps(sanitized, ensure_ascii=False, separators=(",", ":"))
|
|
|
|
|
|
def stateless_next_action_schema(atomic_ids: list[str]) -> dict[str, Any]:
|
|
schema = StatelessNextAction.model_json_schema()
|
|
properties = schema.get("properties", {})
|
|
if isinstance(properties, dict):
|
|
properties["operation"] = {"enum": atomic_ids}
|
|
return schema
|
|
|
|
|
|
def stateless_final_review_schema(visual_claim_count: int) -> dict[str, Any]:
|
|
schema = StatelessFinalReview.model_json_schema()
|
|
properties = schema.get("properties", {})
|
|
visual = properties.get("visual_claims") if isinstance(properties, dict) else None
|
|
if isinstance(visual, dict):
|
|
visual["minItems"] = visual_claim_count
|
|
visual["maxItems"] = visual_claim_count
|
|
return schema
|
|
|
|
|
|
def stateless_rollback_checkpoint_schema(checkpoint_tokens: list[str]) -> dict[str, Any]:
|
|
schema = StatelessRollbackCheckpoint.model_json_schema()
|
|
properties = schema.get("properties", {})
|
|
if isinstance(properties, dict):
|
|
properties["checkpoint_token"] = {"enum": checkpoint_tokens}
|
|
return schema
|
|
|
|
|
|
def topology_request_schema(working_head: str) -> dict[str, Any]:
|
|
schema = TopologyRequest.model_json_schema()
|
|
properties = schema.get("properties", {})
|
|
if isinstance(properties, dict):
|
|
properties["working_head"] = {"const": working_head}
|
|
return schema
|
|
|
|
|
|
def rollback_checkpoint_schema(working_head: str, checkpoint_tokens: list[str]) -> dict[str, Any]:
|
|
"""Bind a rollback request to immutable checkpoints in the active lineage."""
|
|
schema = RollbackCheckpoint.model_json_schema()
|
|
properties = schema.get("properties", {})
|
|
if isinstance(properties, dict):
|
|
properties["working_head"] = {"const": working_head}
|
|
properties["checkpoint_token"] = {"enum": checkpoint_tokens}
|
|
return schema
|
|
|
|
|
|
def _bind_claim_coverage_ids(schema: dict[str, Any], claim_ids: list[str]) -> None:
|
|
definitions = schema.get("$defs", {})
|
|
coverage = definitions.get("ClaimCoverage") if isinstance(definitions, dict) else None
|
|
if not isinstance(coverage, dict):
|
|
return
|
|
properties = coverage.get("properties", {})
|
|
if isinstance(properties, dict):
|
|
properties["claim_id"] = {"enum": claim_ids}
|
|
|
|
|
|
Dto = TypeVar("Dto", bound=StrictDto)
|
|
|
|
|
|
def raw_arguments_hash(raw_arguments_json: str) -> str:
|
|
return hashlib.sha256(raw_arguments_json.encode("utf-8")).hexdigest()
|
|
|
|
|
|
def json_depth(value: Any, current: int = 0) -> int:
|
|
if isinstance(value, dict):
|
|
return max([current, *(json_depth(item, current + 1) for item in value.values())])
|
|
if isinstance(value, list):
|
|
return max([current, *(json_depth(item, current + 1) for item in value)])
|
|
return current
|
|
|
|
|
|
def canonical_json_object(raw_arguments_json: str, *, max_bytes: int = 48_000, max_depth: int = 16) -> dict[str, Any] | WorkflowError:
|
|
"""Bound and parse raw arguments before any schema-specific validation."""
|
|
if len(raw_arguments_json.encode("utf-8")) > max_bytes:
|
|
return WorkflowError(ErrorCode.AUTHOR_FORMAT_INVALID, "Tool arguments exceed the byte limit.")
|
|
try:
|
|
value = json.loads(raw_arguments_json)
|
|
except json.JSONDecodeError as error:
|
|
return WorkflowError(ErrorCode.AUTHOR_FORMAT_INVALID, "Tool arguments are not valid JSON.", field_errors=({"path": "/", "message": error.msg},))
|
|
if not isinstance(value, dict):
|
|
return WorkflowError(ErrorCode.AUTHOR_FORMAT_INVALID, "Tool arguments must be a JSON object.")
|
|
if json_depth(value) > max_depth:
|
|
return WorkflowError(ErrorCode.AUTHOR_FORMAT_INVALID, "Tool arguments exceed the nesting-depth limit.")
|
|
if _contains_non_finite_number(value):
|
|
return WorkflowError(ErrorCode.AUTHOR_FORMAT_INVALID, "Tool arguments must not contain NaN or infinite numbers.")
|
|
return value
|
|
|
|
|
|
def _contains_non_finite_number(value: Any) -> bool:
|
|
if isinstance(value, float):
|
|
return not math.isfinite(value)
|
|
if isinstance(value, dict):
|
|
return any(_contains_non_finite_number(item) for item in value.values())
|
|
if isinstance(value, list):
|
|
return any(_contains_non_finite_number(item) for item in value)
|
|
return False
|
|
|
|
|
|
def canonical_validate_schema(raw_arguments_json: str, schema: dict[str, Any]) -> WorkflowError | None:
|
|
"""Revalidate raw arguments against the current dynamic JSON Schema."""
|
|
value = canonical_json_object(raw_arguments_json)
|
|
if isinstance(value, WorkflowError):
|
|
return value
|
|
errors = [
|
|
{"path": "/" + "/".join(str(part) for part in error.absolute_path), "message": error.message}
|
|
for error in sorted(Draft202012Validator(schema).iter_errors(value), key=lambda item: (list(item.absolute_path), item.message))
|
|
]
|
|
if errors:
|
|
return WorkflowError(ErrorCode.AUTHOR_FORMAT_INVALID, "Tool arguments do not match the active dynamic schema.", field_errors=tuple(errors))
|
|
return None
|
|
|
|
|
|
def canonical_validate(raw_arguments_json: str, model: type[Dto], *, max_bytes: int = 48_000, max_depth: int = 16) -> Dto | WorkflowError:
|
|
"""Parse raw tool arguments once and return field-level DTO failures safely."""
|
|
value = canonical_json_object(raw_arguments_json, max_bytes=max_bytes, max_depth=max_depth)
|
|
if isinstance(value, WorkflowError):
|
|
return value
|
|
try:
|
|
return model.model_validate(value)
|
|
except ValidationError as error:
|
|
fields = tuple({"path": "/" + "/".join(str(part) for part in issue["loc"]), "message": issue["msg"]} for issue in error.errors())
|
|
return WorkflowError(ErrorCode.AUTHOR_FORMAT_INVALID, "Tool arguments do not match the canonical schema.", field_errors=fields)
|
|
|
|
|
|
def validate_one_tool_call(tool_calls: list[dict[str, Any]], allowed_name: str) -> tuple[str, str] | WorkflowError:
|
|
"""Require exactly one known tool call; no provider parser is trusted."""
|
|
if len(tool_calls) != 1:
|
|
return WorkflowError(ErrorCode.AUTHOR_FORMAT_INVALID, "Exactly one tool call is required.")
|
|
function = tool_calls[0].get("function") if isinstance(tool_calls[0], dict) else None
|
|
name = str(function.get("name") or "") if isinstance(function, dict) else ""
|
|
raw = str(function.get("arguments") or "") if isinstance(function, dict) else ""
|
|
if name != allowed_name:
|
|
return WorkflowError(ErrorCode.AUTHOR_FORMAT_INVALID, "The returned tool is not allowed in this workflow state.", details={"expected_tool": allowed_name, "actual_tool": name})
|
|
return name, raw
|