重构 改为标准化输出

This commit is contained in:
2026-09-01 14:10:23 +08:00
parent 7533b298f1
commit bc87bd6d2d
10151 changed files with 1709487 additions and 11776 deletions
@@ -0,0 +1 @@
"""Application command handlers and context assembly for protocol v3."""
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,92 @@
"""Real provider structured-output conformance gate for protocol v3."""
from __future__ import annotations
from hashlib import sha256
import json
from typing import Any
from app.cad_agent.application.llm_contracts import (
EmptyCommand,
GeometryConclusion,
RollbackCheckpoint,
candidate_review_schema,
final_review_schema,
geometry_conclusion_schema,
next_action_schema,
operation_contract_request_schema,
requirements_draft_schema,
requirements_patch_schema,
requirements_review_schema,
rollback_checkpoint_schema,
topology_request_schema,
)
from app.cad_agent.domain.operation_contract import fragment_schema
from app.cad_agent.domain.verifier_registry import default_registry
from app.cad_agent.ports import CadRuntime, ModelGateway
_CONFORMANCE_WORKING_HEAD = "cad_conformance:root:v1"
_CONFORMANCE_REQUIREMENT_IDS = ["req_001"]
_CONFORMANCE_CLAIM_IDS = ["claim_001"]
_CONFORMANCE_DRAFT_IDS = ["draft_001"]
_CONFORMANCE_SOURCE_IDS = ["src_001"]
def conformance_tools(runtime: CadRuntime) -> list[dict[str, Any]]:
"""Return the complete v3 structured-output surface.
A provider is usable only when it can return valid arguments for every
fixed schema and every currently registered runtime operation. This list
intentionally derives the operation portion from the runtime registry so
a newly exposed operation cannot bypass the capability gate.
"""
registry = default_registry()
atomic_ids = list(runtime.supported_atomic_ids())
tools = [
_tool("submit_requirements_draft_batch", requirements_draft_schema(registry.expected_one_of_schema(), _CONFORMANCE_SOURCE_IDS)),
_tool("patch_requirements_draft", requirements_patch_schema(registry.expected_one_of_schema(), _CONFORMANCE_SOURCE_IDS, _CONFORMANCE_DRAFT_IDS)),
_tool("finalize_requirements_draft", EmptyCommand.model_json_schema()),
_tool("review_requirements", requirements_review_schema(_CONFORMANCE_SOURCE_IDS, _CONFORMANCE_DRAFT_IDS)),
_tool("propose_next_action", next_action_schema(_CONFORMANCE_WORKING_HEAD, _CONFORMANCE_REQUIREMENT_IDS, atomic_ids)),
_tool("inspect_topology", topology_request_schema(_CONFORMANCE_WORKING_HEAD)),
_tool("get_cdsl_operation_contract", operation_contract_request_schema(_CONFORMANCE_WORKING_HEAD, atomic_ids[0])),
_tool("review_candidate", candidate_review_schema("candidate_conformance", _CONFORMANCE_WORKING_HEAD, _CONFORMANCE_CLAIM_IDS)),
_tool("complete_task", EmptyCommand.model_json_schema()),
_tool("review_final", final_review_schema(_CONFORMANCE_WORKING_HEAD, _CONFORMANCE_CLAIM_IDS)),
_tool("record_geometry_conclusion", geometry_conclusion_schema(_CONFORMANCE_WORKING_HEAD, ["evidence_current_state"])),
_tool("rollback_checkpoint", rollback_checkpoint_schema(_CONFORMANCE_WORKING_HEAD, ["checkpoint_root"])),
]
if not atomic_ids:
raise RuntimeError("Runtime has no operations for conformance")
for atomic_id in atomic_ids:
contract = runtime.operation_contract(atomic_id)
tools.append(_tool(f"conformance_{atomic_id}", fragment_schema(contract, selector_tokens=["sel_conformance"], reference_tokens=["ref_conformance"])))
return tools
def conformance_hash(tools: list[dict[str, Any]]) -> str:
return sha256(json.dumps(tools, ensure_ascii=True, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest()
async def verify_model_capability(repository: Any, runtime: CadRuntime, models: ModelGateway, *, provider_id: str, model_id: str, force: bool = False) -> dict[str, Any]:
tools = conformance_tools(runtime)
schema_hash = conformance_hash(tools)
cached = repository.model_capability(provider_id, model_id, schema_hash)
if cached is not None and cached["supported"] and not force:
return {"cached": True, "schema_hash": schema_hash, **cached["report"]}
report = await models.conformance(provider_id=provider_id, model_id=model_id, tools=tools)
report = {"schema_hash": schema_hash, "tool_count": len(tools), **report}
repository.record_model_capability(provider_id, model_id, schema_hash, report)
return report
def _tool(name: str, parameters: dict[str, Any]) -> dict[str, Any]:
return {
"type": "function",
"function": {
"name": name,
"description": "Structured output conformance probe. Return one schema-valid call with every required root and nested property.",
"parameters": parameters,
},
}
@@ -0,0 +1,394 @@
"""Canonical schemas for every v3 LLM state-changing command.
Providers may parse structured output first, but this module validates the raw
tool arguments a second time before a command reaches a handler.
"""
from __future__ import annotations
from copy import deepcopy
import hashlib
import json
import math
from typing import Annotated, Any, Literal, TypeVar
from jsonschema import Draft202012Validator
from pydantic import BaseModel, ConfigDict, Field, JsonValue, ValidationError, model_validator
from app.cad_agent.domain.errors import ErrorCode, WorkflowError
class StrictDto(BaseModel):
model_config = ConfigDict(extra="forbid", strict=True, str_strip_whitespace=True)
ShortText = Annotated[str, Field(min_length=1, max_length=360)]
Identifier = Annotated[str, Field(pattern=r"^[a-z][a-z0-9_:-]{0,95}$")]
class AcceptanceClaimInput(StrictDto):
claim_kind: Identifier
expected: dict[str, JsonValue] = Field(min_length=0, max_length=24)
class RequirementInput(StrictDto):
source_ids: list[Identifier] = Field(min_length=1, max_length=32)
statement: Annotated[str, Field(min_length=1, max_length=1000)]
assumptions: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=16)
acceptance_claims: list[AcceptanceClaimInput] = Field(min_length=1, max_length=16)
@model_validator(mode="after")
def _source_ids_are_unique(self) -> "RequirementInput":
if len(self.source_ids) != len(set(self.source_ids)):
raise ValueError("source_ids must not contain duplicates")
return self
class RequirementsDraftBatch(StrictDto):
items: list[RequirementInput] = Field(min_length=1, max_length=8)
class RequirementsPatch(StrictDto):
target_draft_id: Identifier = Field(description="Current server-assigned draft ID to patch. This field belongs inside one patches[] entry.")
op: Literal["replace", "remove"] = Field(description="replace supplies a complete replacement item; remove supplies a reason instead.")
item: RequirementInput | None = Field(default=None, description="Complete replacement RequirementInput for op=replace. It must not include draft_id because the server preserves that ID.")
reason: str | None = Field(default=None, min_length=1, max_length=360, description="Required only for op=remove; explains why the current draft item is removed.")
@model_validator(mode="after")
def _complete_patch(self) -> "RequirementsPatch":
if self.op == "replace" and self.item is None:
raise ValueError("replace requires a complete item")
if self.op == "remove" and (self.item is not None or self.reason is None):
raise ValueError("remove requires a reason and forbids item")
return self
class RequirementsPatchBatch(StrictDto):
patches: list[RequirementsPatch] = Field(min_length=1, max_length=8, description="Patch entries. Example shape: {\"patches\":[{\"target_draft_id\":\"draft_001\",\"op\":\"replace\",\"item\":{...}}]}.")
class EmptyCommand(StrictDto):
pass
class ReviewNormalization(StrictDto):
rule_id: Literal["full_circle_equal_spacing"]
count: int = Field(ge=2, le=1024)
declared_spacing_degrees: float = Field(gt=0, le=360)
full_circle: bool
class ReviewFinding(StrictDto):
draft_id: Identifier
source_ids: list[Identifier] = Field(min_length=1, max_length=32)
finding_type: Literal[
"missing_source_semantics",
"claim_mismatch",
"verification_gap",
"derivable_conflict",
"ambiguous_conflict",
]
description: Annotated[str, Field(min_length=1, max_length=1000)]
question: str | None = Field(default=None, min_length=1, max_length=360)
normalization: ReviewNormalization | None = None
@model_validator(mode="after")
def _finding_payload_matches_type(self) -> "ReviewFinding":
if len(self.source_ids) != len(set(self.source_ids)):
raise ValueError("source_ids must not contain duplicates")
if self.finding_type == "ambiguous_conflict" and self.question is None:
raise ValueError("ambiguous_conflict requires an answerable question")
if self.finding_type == "derivable_conflict" and self.normalization is None:
raise ValueError("derivable_conflict requires structured normalization data")
if self.finding_type != "derivable_conflict" and self.normalization is not None:
raise ValueError("normalization is allowed only for derivable_conflict")
return self
class RequirementsReview(StrictDto):
findings: list[ReviewFinding] = Field(default_factory=list, max_length=128)
class NextAction(StrictDto):
working_head: Annotated[str, Field(pattern=r"^[a-z0-9_:-]{5,192}$")]
intent: ShortText
requirement_ids: list[Identifier] = Field(min_length=1, max_length=5)
atomic_id: Identifier
expected_change: ShortText
@model_validator(mode="after")
def _requirement_ids_are_unique(self) -> "NextAction":
if len(self.requirement_ids) != len(set(self.requirement_ids)):
raise ValueError("requirement_ids must not contain duplicates")
return self
class TopologyRequest(StrictDto):
working_head: Annotated[str, Field(pattern=r"^[a-z0-9_:-]{5,192}$")]
kind: Literal["face", "edge", "vertex", "plane", "axis", "body"] | None = None
limit: int = Field(default=16, ge=1, le=64)
class OperationContractRequest(StrictDto):
working_head: Annotated[str, Field(pattern=r"^[a-z0-9_:-]{5,192}$")]
atomic_id: Identifier
class GeometryConclusion(StrictDto):
working_head: Annotated[str, Field(pattern=r"^[a-z0-9_:-]{5,192}$")]
evidence_refs: list[Identifier] = Field(min_length=1, max_length=16)
root_cause: Annotated[str, Field(min_length=1, max_length=360)]
decision: Literal["return_to_action_selection", "rollback"]
corrective_intent: str | None = Field(default=None, min_length=1, max_length=360)
@model_validator(mode="after")
def _evidence_refs_are_unique(self) -> "GeometryConclusion":
if len(self.evidence_refs) != len(set(self.evidence_refs)):
raise ValueError("evidence_refs must not contain duplicates")
return self
class RollbackCheckpoint(StrictDto):
working_head: Annotated[str, Field(pattern=r"^[a-z0-9_:-]{5,192}$")]
checkpoint_token: Identifier
reason: Annotated[str, Field(min_length=1, max_length=360)]
class ClaimCoverage(StrictDto):
claim_id: Identifier
status: Literal["pass", "pending", "fail", "not_applicable"]
evidence_refs: list[Identifier] = Field(default_factory=list, max_length=16)
@model_validator(mode="after")
def _evidence_refs_are_unique(self) -> "ClaimCoverage":
if len(self.evidence_refs) != len(set(self.evidence_refs)):
raise ValueError("evidence_refs must not contain duplicates")
return self
class CandidateReview(StrictDto):
candidate_id: Identifier = Field(description="Server-issued candidate ID from the review facts.")
working_head: Annotated[str, Field(pattern=r"^[a-z0-9_:-]{5,192}$", description="Current server-issued working head from the review facts.")]
verdict: Literal["accept", "reject"] = Field(description="Required independent decision. Set accept only when the supplied candidate evidence supports every covered claim; otherwise set reject.")
claim_coverage: list[ClaimCoverage] = Field(min_length=1, max_length=128, description="Required coverage decision for every claim ID in the supplied candidate facts.")
evidence: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=16)
issues: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=16)
class FinalReview(StrictDto):
working_head: Annotated[str, Field(pattern=r"^[a-z0-9_:-]{5,192}$", description="Current server-issued working head from the final review facts.")]
verdict: Literal["pass", "repair"] = Field(description="Required independent final decision. Set pass only when the supplied evidence supports every claim; otherwise set repair.")
claim_coverage: list[ClaimCoverage] = Field(min_length=1, max_length=128, description="Required coverage decision for every claim ID in the supplied final-review facts.")
evidence: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=16)
issues: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=16)
def requirements_draft_schema(claim_one_of: dict[str, Any], source_ids: list[str]) -> dict[str, Any]:
"""Bind claim and source enums for the current requirements snapshot."""
schema = RequirementsDraftBatch.model_json_schema()
requirement = schema.get("$defs", {}).get("RequirementInput")
if isinstance(requirement, dict):
properties = requirement.get("properties", {})
source_items = properties.get("source_ids", {}).get("items") if isinstance(properties, dict) and isinstance(properties.get("source_ids"), dict) else None
if isinstance(source_items, dict):
source_items["enum"] = source_ids
claims = requirement.get("properties", {}).get("acceptance_claims")
if isinstance(claims, dict):
claims["items"] = deepcopy(claim_one_of)
return schema
def requirements_patch_schema(claim_one_of: dict[str, Any], source_ids: list[str], draft_ids: list[str]) -> dict[str, Any]:
schema = RequirementsPatchBatch.model_json_schema()
definitions = schema.get("$defs", {})
requirement = definitions.get("RequirementInput") if isinstance(definitions, dict) else None
if isinstance(requirement, dict):
properties = requirement.get("properties", {})
source_items = properties.get("source_ids", {}).get("items") if isinstance(properties, dict) and isinstance(properties.get("source_ids"), dict) else None
if isinstance(source_items, dict):
source_items["enum"] = source_ids
claims = requirement.get("properties", {}).get("acceptance_claims")
if isinstance(claims, dict):
claims["items"] = deepcopy(claim_one_of)
patch = definitions.get("RequirementsPatch") if isinstance(definitions, dict) else None
if isinstance(patch, dict):
target = patch.get("properties", {}).get("target_draft_id")
if isinstance(target, dict):
target["enum"] = draft_ids
return schema
def requirements_review_schema(source_ids: list[str], draft_ids: list[str]) -> dict[str, Any]:
schema = RequirementsReview.model_json_schema()
definitions = schema.get("$defs", {})
finding = definitions.get("ReviewFinding") if isinstance(definitions, dict) else None
if isinstance(finding, dict):
properties = finding.get("properties", {})
if isinstance(properties.get("draft_id"), dict):
properties["draft_id"] = {"enum": draft_ids}
source_items = properties.get("source_ids", {}).get("items") if isinstance(properties.get("source_ids"), dict) else None
if isinstance(source_items, dict):
source_items.clear()
source_items.update({"enum": source_ids})
return schema
def next_action_schema(working_head: str, requirement_ids: list[str], atomic_ids: list[str]) -> dict[str, Any]:
schema = NextAction.model_json_schema()
properties = schema.get("properties", {})
if isinstance(properties, dict):
properties["working_head"] = {"const": working_head}
if isinstance(properties.get("requirement_ids"), dict):
properties["requirement_ids"]["items"] = {"enum": requirement_ids}
properties["atomic_id"] = {"enum": atomic_ids}
return schema
def candidate_review_schema(candidate_id: str, working_head: str, claim_ids: list[str]) -> dict[str, Any]:
"""Bind an independent candidate review to immutable candidate facts."""
schema = CandidateReview.model_json_schema()
properties = schema.get("properties", {})
if isinstance(properties, dict):
properties["candidate_id"] = {"const": candidate_id}
properties["working_head"] = {"const": working_head}
_bind_claim_coverage_ids(schema, claim_ids)
return schema
def final_review_schema(working_head: str, claim_ids: list[str]) -> dict[str, Any]:
"""Bind final review output to the currently reviewable revision."""
schema = FinalReview.model_json_schema()
properties = schema.get("properties", {})
if isinstance(properties, dict):
properties["working_head"] = {"const": working_head}
_bind_claim_coverage_ids(schema, claim_ids)
return schema
def topology_request_schema(working_head: str) -> dict[str, Any]:
schema = TopologyRequest.model_json_schema()
properties = schema.get("properties", {})
if isinstance(properties, dict):
properties["working_head"] = {"const": working_head}
return schema
def operation_contract_request_schema(working_head: str, atomic_id: str) -> dict[str, Any]:
schema = OperationContractRequest.model_json_schema()
properties = schema.get("properties", {})
if isinstance(properties, dict):
properties["working_head"] = {"const": working_head}
properties["atomic_id"] = {"const": atomic_id}
return schema
def geometry_conclusion_schema(working_head: str, evidence_refs: list[str]) -> dict[str, Any]:
"""Bind a diagnostic conclusion to evidence generated for this head."""
schema = GeometryConclusion.model_json_schema()
properties = schema.get("properties", {})
if isinstance(properties, dict):
properties["working_head"] = {"const": working_head}
evidence = properties.get("evidence_refs")
if isinstance(evidence, dict):
evidence["items"] = {"enum": evidence_refs}
return schema
def rollback_checkpoint_schema(working_head: str, checkpoint_tokens: list[str]) -> dict[str, Any]:
"""Bind a rollback request to immutable checkpoints in the active lineage."""
schema = RollbackCheckpoint.model_json_schema()
properties = schema.get("properties", {})
if isinstance(properties, dict):
properties["working_head"] = {"const": working_head}
properties["checkpoint_token"] = {"enum": checkpoint_tokens}
return schema
def _bind_claim_coverage_ids(schema: dict[str, Any], claim_ids: list[str]) -> None:
definitions = schema.get("$defs", {})
coverage = definitions.get("ClaimCoverage") if isinstance(definitions, dict) else None
if not isinstance(coverage, dict):
return
properties = coverage.get("properties", {})
if isinstance(properties, dict):
properties["claim_id"] = {"enum": claim_ids}
Dto = TypeVar("Dto", bound=StrictDto)
def raw_arguments_hash(raw_arguments_json: str) -> str:
return hashlib.sha256(raw_arguments_json.encode("utf-8")).hexdigest()
def json_depth(value: Any, current: int = 0) -> int:
if isinstance(value, dict):
return max([current, *(json_depth(item, current + 1) for item in value.values())])
if isinstance(value, list):
return max([current, *(json_depth(item, current + 1) for item in value)])
return current
def canonical_json_object(raw_arguments_json: str, *, max_bytes: int = 48_000, max_depth: int = 16) -> dict[str, Any] | WorkflowError:
"""Bound and parse raw arguments before any schema-specific validation."""
if len(raw_arguments_json.encode("utf-8")) > max_bytes:
return WorkflowError(ErrorCode.AUTHOR_FORMAT_INVALID, "Tool arguments exceed the byte limit.")
try:
value = json.loads(raw_arguments_json)
except json.JSONDecodeError as error:
return WorkflowError(ErrorCode.AUTHOR_FORMAT_INVALID, "Tool arguments are not valid JSON.", field_errors=({"path": "/", "message": error.msg},))
if not isinstance(value, dict):
return WorkflowError(ErrorCode.AUTHOR_FORMAT_INVALID, "Tool arguments must be a JSON object.")
if json_depth(value) > max_depth:
return WorkflowError(ErrorCode.AUTHOR_FORMAT_INVALID, "Tool arguments exceed the nesting-depth limit.")
if _contains_non_finite_number(value):
return WorkflowError(ErrorCode.AUTHOR_FORMAT_INVALID, "Tool arguments must not contain NaN or infinite numbers.")
return value
def _contains_non_finite_number(value: Any) -> bool:
if isinstance(value, float):
return not math.isfinite(value)
if isinstance(value, dict):
return any(_contains_non_finite_number(item) for item in value.values())
if isinstance(value, list):
return any(_contains_non_finite_number(item) for item in value)
return False
def canonical_validate_schema(raw_arguments_json: str, schema: dict[str, Any]) -> WorkflowError | None:
"""Revalidate raw arguments against the current dynamic JSON Schema."""
value = canonical_json_object(raw_arguments_json)
if isinstance(value, WorkflowError):
return value
errors = [
{"path": "/" + "/".join(str(part) for part in error.absolute_path), "message": error.message}
for error in sorted(Draft202012Validator(schema).iter_errors(value), key=lambda item: (list(item.absolute_path), item.message))
]
if errors:
return WorkflowError(ErrorCode.AUTHOR_FORMAT_INVALID, "Tool arguments do not match the active dynamic schema.", field_errors=tuple(errors))
return None
def canonical_validate(raw_arguments_json: str, model: type[Dto], *, max_bytes: int = 48_000, max_depth: int = 16) -> Dto | WorkflowError:
"""Parse raw tool arguments once and return field-level DTO failures safely."""
value = canonical_json_object(raw_arguments_json, max_bytes=max_bytes, max_depth=max_depth)
if isinstance(value, WorkflowError):
return value
try:
return model.model_validate(value)
except ValidationError as error:
fields = tuple({"path": "/" + "/".join(str(part) for part in issue["loc"]), "message": issue["msg"]} for issue in error.errors())
return WorkflowError(ErrorCode.AUTHOR_FORMAT_INVALID, "Tool arguments do not match the canonical schema.", field_errors=fields)
def validate_one_tool_call(tool_calls: list[dict[str, Any]], allowed_name: str) -> tuple[str, str] | WorkflowError:
"""Require exactly one known tool call; no provider parser is trusted."""
if len(tool_calls) != 1:
return WorkflowError(ErrorCode.AUTHOR_FORMAT_INVALID, "Exactly one tool call is required.")
function = tool_calls[0].get("function") if isinstance(tool_calls[0], dict) else None
name = str(function.get("name") or "") if isinstance(function, dict) else ""
raw = str(function.get("arguments") or "") if isinstance(function, dict) else ""
if name != allowed_name:
return WorkflowError(ErrorCode.AUTHOR_FORMAT_INVALID, "The returned tool is not allowed in this workflow state.", details={"expected_tool": allowed_name, "actual_tool": name})
return name, raw
@@ -0,0 +1,49 @@
"""Versioned, deterministic requirement-conflict resolutions."""
from __future__ import annotations
import math
from typing import Any
REGISTRY_VERSION = "cad.resolution-registry.v1"
FULL_CIRCLE_EQUAL_SPACING_VERSION = "full_circle_equal_spacing.v1"
class NormalizationError(ValueError):
pass
def resolve_normalization(
*,
task_id: str,
draft_id: str,
source_ids: list[str],
description: str,
value: dict[str, Any],
) -> dict[str, Any]:
if value.get("rule_id") != "full_circle_equal_spacing":
raise NormalizationError("Unknown deterministic normalization rule.")
count = value.get("count")
declared = value.get("declared_spacing_degrees")
if not isinstance(count, int) or isinstance(count, bool) or count < 2:
raise NormalizationError("Full-circle equal spacing requires an integer count of at least 2.")
if value.get("full_circle") is not True:
raise NormalizationError("Automatic equal-spacing normalization is allowed only for a full circle.")
if not isinstance(declared, (int, float)) or isinstance(declared, bool) or not math.isfinite(float(declared)):
raise NormalizationError("Declared angular spacing must be finite.")
adopted = 360.0 / count
if math.isclose(float(declared), adopted, rel_tol=0.0, abs_tol=1e-9):
raise NormalizationError("The declared spacing already matches 360/count; there is no derivable conflict.")
return {
"schema_version": "cad.requirements-normalization.v1",
"registry_version": REGISTRY_VERSION,
"rule_id": "full_circle_equal_spacing",
"rule_version": FULL_CIRCLE_EQUAL_SPACING_VERSION,
"task_id": task_id,
"draft_id": draft_id,
"source_ids": list(source_ids),
"original": {"count": count, "spacing_degrees": float(declared), "full_circle": True},
"adopted": {"count": count, "spacing_degrees": adopted, "full_circle": True},
"reason": description,
}
@@ -0,0 +1,37 @@
"""Transactional-outbox delivery orchestration.
State transitions persist an outbox row in the same SQLite transaction. This
dispatcher is the sole path that acknowledges a row after a delivery adapter
accepts it, which gives consumers a stable event ID for idempotent handling.
"""
from __future__ import annotations
from typing import Any
from app.cad_agent.ports import EventPublisher, TaskRepository
class OutboxDispatcher:
def __init__(self, repository: TaskRepository, publisher: EventPublisher) -> None:
self.repository = repository
self.publisher = publisher
async def dispatch_pending(self, *, limit: int = 100, task_id: str | None = None) -> list[dict[str, Any]]:
"""Deliver pending events in order and acknowledge only on success.
A process interruption after ``publish`` but before acknowledgement is
deliberately delivered again. Consumers use the stable ``event_id``
to collapse that at-least-once replay.
"""
delivered: list[dict[str, Any]] = []
for item in self.repository.pending_outbox(limit, task_id=task_id):
event_id = item.get("event_id")
task_id = item.get("task_id")
if not isinstance(event_id, int) or not isinstance(task_id, str):
raise ValueError("Outbox row has no stable event identity")
envelope = {**item, "event_id": event_id, "task_id": task_id}
await self.publisher.publish(envelope)
self.repository.mark_outbox_published(event_id)
delivered.append(envelope)
return delivered
@@ -0,0 +1,665 @@
"""Requirements drafting/review handlers; Markdown is rendered, never parsed."""
from __future__ import annotations
from copy import deepcopy
from hashlib import sha256
import json
from typing import Any
from app.cad_agent.application.llm_contracts import (
RequirementsDraftBatch,
RequirementsPatchBatch,
RequirementsReview,
requirements_draft_schema,
requirements_patch_schema,
requirements_review_schema,
)
from app.cad_agent.application.normalization import NormalizationError, resolve_normalization
from app.cad_agent.application.results import Accepted, Rejected, Waiting
from app.cad_agent.domain.claim_matching import contains_expected
from app.cad_agent.domain.errors import ErrorCode, WorkflowError
from app.cad_agent.domain.state import TaskPhase, TaskState, transition
from app.cad_agent.domain.verifier_registry import VerifierRegistry
from app.cad_agent.ports import ArtifactStore, TaskRepository
class RequirementsCommandHandler:
def __init__(self, repository: TaskRepository, artifacts: ArtifactStore, registry: VerifierRegistry) -> None:
self.repository = repository
self.artifacts = artifacts
self.registry = registry
# This intentionally has no production configuration path. The live
# evaluator registers its fixture oracle per task before authoring.
self._evaluation_contract_oracles: dict[str, list[dict[str, Any]]] = {}
self._evaluation_capability_gaps: dict[str, list[dict[str, str]]] = {}
def register_evaluation_contract_oracle(
self,
task_id: str,
required_claims: list[dict[str, Any]],
*,
validation_capability_gaps: list[dict[str, Any]] | None = None,
) -> None:
"""Register an external contract oracle for one live-evaluation task.
The source requirement remains evidence for normal production work;
this guarded fixture data exists only to keep an evaluation from
accepting a reviewer false positive before CAD execution starts.
"""
normalized: list[dict[str, Any]] = []
for item in required_claims:
claim_kind = item.get("claim_kind") if isinstance(item, dict) else None
expected = item.get("expected") if isinstance(item, dict) else None
if (
not isinstance(claim_kind, str)
or claim_kind not in self.registry.claim_kinds
or not isinstance(expected, dict)
):
raise ValueError("Evaluation contract oracle claims require claim_kind and expected object.")
# The frozen contract is production-valid and complete. Fixture
# expectations are intentionally partial so they can compare
# provider-chosen optional fields such as tolerances.
normalized.append({"claim_kind": claim_kind, "expected": deepcopy(expected)})
if not normalized:
raise ValueError("Evaluation contract oracle requires at least one claim.")
self._evaluation_contract_oracles[task_id] = normalized
gaps: list[dict[str, str]] = []
for gap in validation_capability_gaps or ():
gap_id = gap.get("id") if isinstance(gap, dict) else None
description = gap.get("description") if isinstance(gap, dict) else None
if not isinstance(gap_id, str) or not gap_id or not isinstance(description, str) or not description:
raise ValueError("Evaluation capability gaps require id and description.")
gaps.append({"id": gap_id, "description": description})
self._evaluation_capability_gaps[task_id] = gaps
def evaluation_review_context(self, task_id: str) -> dict[str, Any] | None:
"""Expose fixture-only known verifier gaps to the live reviewer."""
gaps = self._evaluation_capability_gaps.get(task_id)
if gaps is None:
return None
return {"evaluation_only": True, "known_validation_capability_gaps": deepcopy(gaps)}
def draft_schema(self, task_id: str) -> dict[str, Any]:
return requirements_draft_schema(self.registry.expected_one_of_schema(), list(self.artifacts.read_source_index(task_id)))
def patch_schema(self, task_id: str) -> dict[str, Any]:
state = self.repository.get_state(task_id)
draft = self._draft(state)
ids = [str(item.get("draft_id") or "") for item in draft.get("items") or () if isinstance(item, dict) and item.get("draft_id")]
return requirements_patch_schema(self.registry.expected_one_of_schema(), list(self.artifacts.read_source_index(task_id)), ids)
def review_schema(self, task_id: str) -> dict[str, Any]:
state = self.repository.get_state(task_id)
draft = self._draft(state)
draft_ids = [str(item.get("draft_id") or "") for item in draft.get("items") or () if isinstance(item, dict) and item.get("draft_id")]
source_ids = list(self.artifacts.read_source_index(task_id))
return requirements_review_schema(source_ids, draft_ids)
def submit_draft(self, task_id: str, batch: RequirementsDraftBatch, *, invocation_id: str) -> Accepted | Rejected:
replay = self._replay(task_id, invocation_id)
if replay is not None:
return replay
state = self.repository.get_state(task_id)
if state is None:
return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "Task does not exist."))
if state.phase != TaskPhase.DRAFTING_REQUIREMENTS:
return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "Requirements cannot be drafted in the current workflow phase."))
draft = self._draft(state)
source_index = self.artifacts.read_source_index(task_id)
current = [deepcopy(item) for item in draft.get("items") or () if isinstance(item, dict)]
errors = self._validate_items(batch.items, source_index, current)
if errors:
return Rejected(WorkflowError(ErrorCode.AUTHOR_FORMAT_INVALID, "Requirements draft batch failed canonical validation.", field_errors=tuple(errors)))
invocation = self.repository.begin_invocation(
task_id,
invocation_id,
self._key(task_id, "requirements_draft", state.working_head, batch.model_dump(mode="json")),
)
if invocation.status == "finished" and invocation.result is not None:
return self._restore(invocation.result)
start = len(current) + 1
for position, item in enumerate(batch.items, start):
current.append({"draft_id": f"draft_{position:03d}", **item.model_dump(mode="json")})
payload = {"schema_version": "cad.requirements-draft.v1", "revision": int(draft.get("revision") or 0) + 1, "items": current}
try:
artifact_path = self.artifacts.write_requirements_draft(task_id, payload, invocation_id=invocation_id)
except OSError as error:
return self._park_for_storage_retry(
state,
event="requirements_draft_storage_failure",
message=str(error),
)
next_state = transition(state, "draft_updated", requirements_draft_path=artifact_path)
result = Accepted({"draft_revision": payload["revision"], "draft_ids": [item["draft_id"] for item in current[-len(batch.items):]]})
if not self._commit(next_state, [{"event": "requirements_draft_updated", "invocation_id": invocation_id, "draft_revision": payload["revision"], "draft_path": artifact_path, "item_count": len(current)}], invocation, result):
return Rejected(self._stale())
return result
def patch_draft(self, task_id: str, batch: RequirementsPatchBatch, *, invocation_id: str) -> Accepted | Rejected:
replay = self._replay(task_id, invocation_id)
if replay is not None:
return replay
state = self.repository.get_state(task_id)
if state is None or state.phase != TaskPhase.DRAFTING_REQUIREMENTS:
return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "Requirements patches are not allowed in the current workflow phase."))
draft = self._draft(state)
items = [deepcopy(item) for item in draft.get("items") or () if isinstance(item, dict)]
by_id = {str(item.get("draft_id") or ""): item for item in items}
source_index = self.artifacts.read_source_index(task_id)
errors: list[dict[str, str]] = []
seen: set[str] = set()
for index, patch in enumerate(batch.patches):
if patch.target_draft_id in seen:
errors.append({"path": f"/patches/{index}/target_draft_id", "message": "A draft item may be patched only once per batch."})
seen.add(patch.target_draft_id)
if patch.target_draft_id not in by_id:
errors.append({"path": f"/patches/{index}/target_draft_id", "message": "Unknown current draft ID."})
if patch.item:
errors.extend(self._validate_items([patch.item], source_index, [item for item in items if item.get("draft_id") != patch.target_draft_id], prefix=f"/patches/{index}/item"))
if errors:
return Rejected(WorkflowError(ErrorCode.AUTHOR_FORMAT_INVALID, "Requirements patch batch failed canonical validation.", field_errors=tuple(errors)))
invocation = self.repository.begin_invocation(
task_id,
invocation_id,
self._key(task_id, "requirements_patch", state.working_head, batch.model_dump(mode="json")),
)
if invocation.status == "finished" and invocation.result is not None:
return self._restore(invocation.result)
remaining: list[dict[str, Any]] = []
patches = {patch.target_draft_id: patch for patch in batch.patches}
for item in items:
patch = patches.get(str(item["draft_id"]))
if patch is None:
remaining.append(item)
elif patch.op == "replace" and patch.item is not None:
remaining.append({"draft_id": item["draft_id"], **patch.item.model_dump(mode="json")})
payload = {"schema_version": "cad.requirements-draft.v1", "revision": int(draft.get("revision") or 0) + 1, "items": remaining}
try:
artifact_path = self.artifacts.write_requirements_draft(task_id, payload, invocation_id=invocation_id)
except OSError as error:
return self._park_for_storage_retry(
state,
event="requirements_patch_storage_failure",
message=str(error),
)
# A successful patch creates a new draft revision. The prior review
# describes the old revision and must not keep the author trapped in
# patch-only mode; it will be replaced after explicit re-finalization.
next_state = transition(
state,
"draft_updated",
requirements_draft_path=artifact_path,
requirements_review_path="",
)
result = Accepted({"draft_revision": payload["revision"], "item_count": len(remaining)})
if not self._commit(next_state, [{"event": "requirements_draft_patched", "invocation_id": invocation_id, "draft_revision": payload["revision"], "draft_path": artifact_path, "item_count": len(remaining)}], invocation, result):
return Rejected(self._stale())
return result
def finalize_draft(self, task_id: str, *, invocation_id: str) -> Accepted | Rejected:
replay = self._replay(task_id, invocation_id)
if replay is not None:
return replay
state = self.repository.get_state(task_id)
if state is None or state.phase != TaskPhase.DRAFTING_REQUIREMENTS:
return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "Requirements cannot be finalized in the current workflow phase."))
draft = self._draft(state)
items = [item for item in draft.get("items") or () if isinstance(item, dict)]
source_index = self.artifacts.read_source_index(task_id)
covered = {str(source) for item in items for source in item.get("source_ids") or ()}
missing = sorted(set(source_index) - covered)
if not items or missing:
return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "REQUIREMENTS_COVERAGE_INCOMPLETE", details={"missing_source_ids": missing}))
invocation = self.repository.begin_invocation(task_id, invocation_id, self._key(task_id, "requirements_finalize", state.working_head, {}))
if invocation.status == "finished" and invocation.result is not None:
return self._restore(invocation.result)
next_state = transition(state, "requirements_finalized")
result = Accepted({"phase": next_state.phase.value, "draft_revision": draft.get("revision", 0)})
if not self._commit(next_state, [{"event": "requirements_review_requested", "invocation_id": invocation_id, "draft_revision": draft.get("revision", 0)}], invocation, result):
return Rejected(self._stale())
return result
def record_review(self, task_id: str, review: RequirementsReview, *, invocation_id: str) -> Accepted | Rejected | Waiting:
replay = self._replay(task_id, invocation_id)
if replay is not None:
return replay
state = self.repository.get_state(task_id)
if state is None or state.phase != TaskPhase.REVIEWING_REQUIREMENTS:
return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "Requirements review is not expected in the current workflow phase."))
draft = self._draft(state)
items = [item for item in draft.get("items") or () if isinstance(item, dict)]
source_ids = set(self.artifacts.read_source_index(task_id))
draft_ids = {str(item.get("draft_id") or "") for item in items}
errors = self._validate_review(review, source_ids, items, draft_ids)
if errors:
return Rejected(WorkflowError(ErrorCode.AUTHOR_FORMAT_INVALID, "Requirements review failed canonical validation.", field_errors=tuple(errors)))
invocation = self.repository.begin_invocation(
task_id,
invocation_id,
self._key(task_id, "requirements_review", state.working_head, review.model_dump(mode="json")),
)
if invocation.status == "finished" and invocation.result is not None:
return self._restore(invocation.result)
coverage = self._derive_coverage(source_ids, items)
normalizations: list[dict[str, Any]] = []
normalization_errors: list[dict[str, str]] = []
for index, finding in enumerate(review.findings):
if finding.finding_type != "derivable_conflict" or finding.normalization is None:
continue
try:
normalizations.append(resolve_normalization(
task_id=task_id,
draft_id=finding.draft_id,
source_ids=list(finding.source_ids),
description=finding.description,
value=finding.normalization.model_dump(mode="json"),
))
except NormalizationError as error:
normalization_errors.append({"path": f"/findings/{index}/normalization", "message": str(error)})
if normalization_errors:
return Rejected(WorkflowError(
ErrorCode.AUTHOR_FORMAT_INVALID,
"Requirements review supplied an invalid deterministic normalization.",
field_errors=tuple(normalization_errors),
))
warnings = [
finding.description
for finding in review.findings
if finding.finding_type == "verification_gap"
]
author_findings = [
finding for finding in review.findings
if finding.finding_type in {"missing_source_semantics", "claim_mismatch"}
or (
finding.finding_type == "verification_gap"
and not self._draft_has_visual_claim(items, finding.draft_id)
)
]
ambiguous = [finding for finding in review.findings if finding.finding_type == "ambiguous_conflict"]
decision = "waiting_for_user" if ambiguous else "revise" if author_findings else "freeze"
review_payload = {
"schema_version": "cad.requirements-review.v2",
"findings": [finding.model_dump(mode="json") for finding in review.findings],
"coverage": coverage,
"decision": decision,
"verification_warnings": warnings,
"applied_normalizations": normalizations,
}
try:
review_path = self.artifacts.write_requirements_review(task_id, review_payload, invocation_id=invocation_id)
except OSError as error:
return self._park_for_storage_retry(
state,
event="requirements_review_storage_failure",
message=str(error),
)
if ambiguous:
next_state = transition(state, "waiting_for_user", error=ErrorCode.WAITING_FOR_USER, requirements_review_path=review_path)
questions = [str(finding.question) for finding in ambiguous if finding.question]
message = "Requirements contain an ambiguity that cannot be resolved deterministically."
ambiguous_findings = [finding.model_dump(mode="json") for finding in ambiguous]
result = Waiting(WorkflowError(
ErrorCode.WAITING_FOR_USER,
message,
details={"questions": questions, "findings": ambiguous_findings},
))
if not self._commit(next_state, [{
"event": "requirements_waiting_for_user",
"invocation_id": invocation_id,
"review_path": review_path,
"message": message,
"questions": questions,
"findings": ambiguous_findings,
}], invocation, result):
return Rejected(self._stale())
return result
if author_findings:
next_state = transition(state, "requirements_revise", requirements_review_path=review_path)
result = Accepted({"phase": next_state.phase.value, "review": review_payload})
if not self._commit(next_state, [{"event": "requirements_review_revise", "invocation_id": invocation_id, "review_path": review_path}], invocation, result):
return Rejected(self._stale())
return result
if normalizations:
normalization_digest = sha256(
json.dumps(normalizations, ensure_ascii=True, sort_keys=True, separators=(",", ":")).encode("utf-8")
).hexdigest()[:12]
try:
normalization_path = self.artifacts.write_json_once(
task_id,
f"documents/requirements-normalizations-{normalization_digest}.json",
{
"schema_version": "cad.requirements-normalizations.v1",
"task_id": task_id,
"items": normalizations,
},
)
except OSError as error:
return self._park_for_storage_retry(
state,
event="requirements_normalization_storage_failure",
message=str(error),
)
else:
normalization_path = ""
contract = self._freeze_contract(
task_id,
items,
verification_warnings=warnings,
applied_normalizations=normalizations,
)
missing_claims = self._missing_evaluation_oracle_claims(task_id, contract)
if missing_claims:
oracle_payload = self._evaluation_oracle_failure(
task_id,
draft_revision=int(draft.get("revision") or 0),
reviewer_review_path=review_path,
draft_ids=sorted(draft_ids),
missing_claims=missing_claims,
)
oracle_digest = sha256(
json.dumps(oracle_payload, ensure_ascii=True, sort_keys=True, separators=(",", ":")).encode("utf-8")
).hexdigest()[:12]
try:
oracle_path = self.artifacts.write_json_once(
task_id,
f"documents/evaluation-contract-oracle-{oracle_digest}.json",
oracle_payload,
)
except OSError as error:
return self._park_for_storage_retry(
state,
event="evaluation_contract_oracle_storage_failure",
message=str(error),
)
next_state = transition(state, "requirements_revise", requirements_review_path=oracle_path)
result = Accepted({
"phase": next_state.phase.value,
"review": oracle_payload,
"reviewer_review_path": review_path,
"evaluation_oracle": {"missing_claims": missing_claims},
})
if not self._commit(next_state, [{
"event": "requirements_evaluation_oracle_revise",
"invocation_id": invocation_id,
"review_path": review_path,
"oracle_path": oracle_path,
"missing_claims": missing_claims,
}], invocation, result):
return Rejected(self._stale())
return result
try:
contract_path = self.artifacts.write_requirements_contract(task_id, contract, invocation_id=invocation_id)
except OSError as error:
return self._park_for_storage_retry(
state,
event="requirements_contract_storage_failure",
message=str(error),
)
next_state = transition(
state,
"requirements_approved",
requirements_review_path=review_path,
requirements_contract_path=contract_path,
)
result = Accepted({
"phase": next_state.phase.value,
"contract_hash": contract["contract_hash"],
"verification_warnings": warnings,
"applied_normalizations": normalizations,
})
events = []
if normalizations:
events.append({
"event": "requirements_normalized",
"normalization_path": normalization_path,
"applied_normalizations": normalizations,
})
events.append({"event": "requirements_contract_frozen", "invocation_id": invocation_id, "contract_hash": contract["contract_hash"], "contract_path": contract_path, "review_path": review_path, "requirement_count": len(contract["requirements"]), "verification_warnings": warnings, "applied_normalizations": normalizations})
if not self._commit(next_state, events, invocation, result):
return Rejected(self._stale())
return result
def ensure_rendered_contract_views(self, task_id: str, state: TaskState) -> None:
"""Rebuild read-only contract views from the committed immutable path.
The SQLite transition is authoritative. Rendering convenience files
after its commit must never reclassify a frozen contract as an
internal authoring failure, so this idempotent operation is replayed
by the workflow until every view exists.
"""
if not state.requirements_contract_path:
return
contract = self.artifacts.read_requirements_contract(task_id, state.requirements_contract_path)
if not isinstance(contract, dict):
raise RuntimeError("Committed requirements contract is unavailable")
self.artifacts.write_requirements_contract(task_id, contract)
self.artifacts.write_json_once(task_id, "requirements-index.json", {
"schema_version": "cad.requirements-index.v1",
"requirements": [
{
"requirement_id": item["requirement_id"],
"claim_ids": [claim["claim_id"] for claim in item["acceptance_claims"]],
}
for item in contract.get("requirements") or ()
if isinstance(item, dict)
],
})
self.artifacts.write_text_once(task_id, "requirements.md", self._requirements_markdown(contract))
self.artifacts.write_text_once(task_id, "completion.md", self._completion_markdown(contract))
def _replay(self, task_id: str, invocation_id: str) -> Accepted | Waiting | None:
invocation = self.repository.get_invocation(task_id, invocation_id)
if invocation is None or invocation.status != "finished" or invocation.result is None:
return None
return self._restore(invocation.result)
def _finish(self, invocation_id: str, result: Accepted | Waiting) -> None:
if isinstance(result, Waiting):
payload = {"result_type": "waiting", "error": result.error.payload()}
else:
payload = {"result_type": "accepted", "payload": result.payload}
self.repository.finish_invocation(invocation_id, payload)
def _commit(self, state: TaskState, events: list[dict[str, Any]], invocation: Any, result: Accepted | Waiting) -> bool:
if isinstance(result, Waiting):
payload = {"result_type": "waiting", "error": result.error.payload()}
else:
payload = {"result_type": "accepted", "payload": result.payload}
return self.repository.compare_and_swap(
state,
events=events,
invocation_id=invocation.invocation_id,
invocation_result=payload,
)
@staticmethod
def _restore(payload: dict[str, Any]) -> Accepted | Waiting:
if payload.get("result_type") == "waiting":
error = payload.get("error") if isinstance(payload.get("error"), dict) else {}
return Waiting(WorkflowError(
ErrorCode(str(error.get("code") or ErrorCode.WAITING_FOR_USER.value)),
str(error.get("message") or "Requirements need a user decision."),
tuple(error.get("field_errors") or ()),
bool(error.get("retryable")),
dict(error.get("details") or {}),
))
value = payload.get("payload") if isinstance(payload.get("payload"), dict) else payload
return Accepted(value)
def _draft(self, state: TaskState | None) -> dict[str, Any]:
return self.artifacts.read_requirements_draft(state.task_id, state.requirements_draft_path) if state is not None else {"schema_version": "cad.requirements-draft.v1", "revision": 0, "items": []}
@staticmethod
def _key(task_id: str, kind: str, head: str, value: dict[str, Any]) -> str:
encoded = json.dumps(value, ensure_ascii=True, sort_keys=True, separators=(",", ":"))
return sha256(f"{task_id}|{kind}|{head}|{encoded}".encode("utf-8")).hexdigest()
def _validate_items(self, inputs: list[Any], source_index: dict[str, str], existing: list[dict[str, Any]], *, prefix: str = "/items") -> list[dict[str, str]]:
errors: list[dict[str, str]] = []
seen = {(tuple(item.get("source_ids") or ()), str(item.get("statement") or "").casefold()) for item in existing}
for index, item in enumerate(inputs):
path = f"{prefix}/{index}"
if not set(item.source_ids).issubset(source_index):
errors.append({"path": f"{path}/source_ids", "message": "source_ids must come from the current source index."})
signature = (tuple(item.source_ids), item.statement.casefold())
if signature in seen:
errors.append({"path": f"{path}/statement", "message": "Duplicate requirement item."})
seen.add(signature)
for claim_index, claim in enumerate(item.acceptance_claims):
try:
claim_errors = self.registry.validate_expected(claim.claim_kind, claim.expected)
except ValueError:
errors.append({"path": f"{path}/acceptance_claims/{claim_index}/claim_kind", "message": "VERIFIER_UNAVAILABLE"})
continue
errors.extend({"path": f"{path}/acceptance_claims/{claim_index}/expected{error['path']}", "message": error["message"]} for error in claim_errors)
return errors
@staticmethod
def _validate_review(
review: RequirementsReview,
source_ids: set[str],
items: list[dict[str, Any]],
draft_ids: set[str],
) -> list[dict[str, str]]:
errors: list[dict[str, str]] = []
by_draft = {str(item.get("draft_id") or ""): item for item in items}
for index, finding in enumerate(review.findings):
if finding.draft_id not in draft_ids:
errors.append({"path": f"/findings/{index}/draft_id", "message": "Finding must reference a current draft item."})
continue
if not set(finding.source_ids).issubset(source_ids):
errors.append({"path": f"/findings/{index}/source_ids", "message": "Finding source_ids must come from the current source index."})
continue
cited = set(by_draft[finding.draft_id].get("source_ids") or ())
if not set(finding.source_ids).issubset(cited):
errors.append({"path": f"/findings/{index}/source_ids", "message": "Finding source_ids must be cited by its draft item."})
return errors
@staticmethod
def _derive_coverage(source_ids: set[str], items: list[dict[str, Any]]) -> list[dict[str, Any]]:
coverage = {source_id: [] for source_id in sorted(source_ids)}
for position, item in enumerate(items, 1):
requirement_id = f"req_{position:03d}"
for source_id in item.get("source_ids") or ():
if source_id in coverage:
coverage[source_id].append(requirement_id)
return [{"source_id": source_id, "requirement_ids": requirement_ids} for source_id, requirement_ids in coverage.items()]
@staticmethod
def _draft_has_visual_claim(items: list[dict[str, Any]], draft_id: str) -> bool:
return any(
claim.get("claim_kind") == "visual"
for item in items
if item.get("draft_id") == draft_id
for claim in item.get("acceptance_claims") or ()
if isinstance(claim, dict)
)
def _freeze_contract(
self,
task_id: str,
items: list[dict[str, Any]],
*,
verification_warnings: list[str],
applied_normalizations: list[dict[str, Any]],
) -> dict[str, Any]:
requirements: list[dict[str, Any]] = []
claim_position = 1
for position, item in enumerate(items, 1):
claims: list[dict[str, Any]] = []
for claim in item.get("acceptance_claims") or ():
deterministic = self.registry.definition(str(claim["claim_kind"])).deterministic
claims.append({"claim_id": f"claim_{claim_position:03d}", "claim_kind": claim["claim_kind"], "expected": claim["expected"], "verification_mode": "deterministic" if deterministic else "visual"})
claim_position += 1
requirements.append({"requirement_id": f"req_{position:03d}", "draft_id": item["draft_id"], "source_ids": item["source_ids"], "statement": item["statement"], "assumptions": item["assumptions"], "acceptance_claims": claims})
payload = {
"schema_version": "cad.requirements-contract.v2",
"task_id": task_id,
"requirements": requirements,
"verification_warnings": list(verification_warnings),
"applied_normalizations": deepcopy(applied_normalizations),
}
payload["contract_hash"] = sha256(json.dumps(payload, ensure_ascii=True, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest()
return payload
def _missing_evaluation_oracle_claims(self, task_id: str, contract: dict[str, Any]) -> list[dict[str, Any]]:
required = self._evaluation_contract_oracles.get(task_id)
if not required:
return []
actual = [
{"claim_kind": str(claim.get("claim_kind") or ""), "expected": claim.get("expected")}
for requirement in contract.get("requirements") or ()
if isinstance(requirement, dict)
for claim in requirement.get("acceptance_claims") or ()
if isinstance(claim, dict) and isinstance(claim.get("expected"), dict)
]
return [
deepcopy(claim)
for claim in required
if not any(
actual_claim["claim_kind"] == claim["claim_kind"]
and contains_expected(actual_claim["expected"], claim["expected"])
for actual_claim in actual
)
]
@staticmethod
def _evaluation_oracle_failure(
task_id: str,
*,
draft_revision: int,
reviewer_review_path: str,
draft_ids: list[str],
missing_claims: list[dict[str, Any]],
) -> dict[str, Any]:
return {
"schema_version": "cad.requirements-evaluation-oracle.v1",
"task_id": task_id,
"evaluation_only": True,
"decision": "revise",
"draft_revision": draft_revision,
"reviewer_decision": "freeze",
"reviewer_review_path": reviewer_review_path,
"findings": [
{
"draft_id": draft_id,
"source_ids": [],
"finding_type": "missing_source_semantics",
"description": "The external live-evaluation contract oracle found missing executable acceptance claims.",
}
for draft_id in draft_ids
],
"missing_claims": missing_claims,
}
@staticmethod
def _requirements_markdown(contract: dict[str, Any]) -> str:
rows = ["# Requirements", ""]
for item in contract["requirements"]:
rows.append(f"- {item['requirement_id']}: {item['statement']}")
for assumption in item["assumptions"]:
rows.append(f" - Assumption: {assumption}")
return "\n".join(rows) + "\n"
@staticmethod
def _completion_markdown(contract: dict[str, Any]) -> str:
return "# Completion\n\n" + "\n".join(f"- [ ] {item['requirement_id']}: {item['statement']}" for item in contract["requirements"]) + "\n"
@staticmethod
def _stale() -> WorkflowError:
return WorkflowError(ErrorCode.STALE_WORKING_HEAD, "Task state changed before this command could commit.")
def _park_for_storage_retry(self, state: TaskState, *, event: str, message: str) -> Rejected:
waiting = transition(state, "waiting_retry", error=ErrorCode.STORAGE_FAILURE)
if not self.repository.compare_and_swap(waiting, events=[{
"event": event,
"code": ErrorCode.STORAGE_FAILURE.value,
"message": message[:1000],
}]):
return Rejected(self._stale())
return Rejected(WorkflowError(
ErrorCode.STORAGE_FAILURE,
"Requirements artifact storage is temporarily unavailable; the task can be resumed.",
retryable=True,
))
@@ -0,0 +1,29 @@
"""Typed results returned by all command handlers."""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
from app.cad_agent.domain.errors import WorkflowError
@dataclass(frozen=True, slots=True)
class Accepted:
payload: dict[str, Any] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class Rejected:
error: WorkflowError
@dataclass(frozen=True, slots=True)
class Waiting:
error: WorkflowError
@dataclass(frozen=True, slots=True)
class FailedInternal:
correlation_id: str
message: str
File diff suppressed because it is too large Load Diff