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