Files
cdsl-cad/backend/app/cad_agent/application/requirements.py
T

671 lines
39 KiB
Python

"""Immutable Markdown-first requirements artifacts and compiled contracts."""
from __future__ import annotations
from copy import deepcopy
from hashlib import sha256
import json
import re
from typing import Any, Callable
from app.cad_agent.application.llm_contracts import AcceptanceClaimInput, CompiledRequirementsSpec, MarkdownDocument, compiled_requirements_schema
from app.cad_agent.domain.feature_plan import FeaturePlan, FeatureScheduler, node_hash, plan_hash, validate_feature_plan
from app.cad_agent.application.results import Accepted, Rejected
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
_CHECKBOX = re.compile(r"^\s*- \[ \]\s+(.+?)\s*$")
_RECORD_BOUND_CLAIMS = frozenset({"coaxial", "coplanar"})
_CENTERED_BORE_MARKERS = ("centered", "concentric", "coaxial", "中心", "同心", "同轴")
_BORE_MARKERS = ("bore", "hole", "孔")
_OBROUND_SLOT_MARKERS = ("oblong", "slot", "slotted", "腰形", "长圆", "调节槽")
class RequirementsCommandHandler:
"""Persist frozen documents and compile their checklist into a contract.
The model never names targets or internal objects during compilation. The
service derives those values strictly from the immutable checklist.
"""
def __init__(self, repository: TaskRepository, artifacts: ArtifactStore, registry: VerifierRegistry, *, atomic_ids: Callable[[], tuple[str, ...]] | None = None) -> None:
self.repository = repository
self.artifacts = artifacts
self.registry = registry
self.atomic_ids = atomic_ids or (lambda: ())
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:
self._evaluation_contract_oracles[task_id] = deepcopy(required_claims)
self._evaluation_capability_gaps[task_id] = [
{"id": str(item.get("id") or ""), "description": str(item.get("description") or "")}
for item in validation_capability_gaps or () if isinstance(item, dict)
]
def evaluation_review_context(self, task_id: str) -> dict[str, Any] | None:
claims = self._evaluation_contract_oracles.get(task_id)
return None if claims is None else {
"evaluation_only": True,
"required_claims": deepcopy(claims),
"known_validation_capability_gaps": deepcopy(self._evaluation_capability_gaps.get(task_id, [])),
}
@staticmethod
def document_schema() -> dict[str, Any]:
return MarkdownDocument.model_json_schema()
def compiler_schema(self, task_id: str) -> dict[str, Any]:
return compiled_requirements_schema(
self.registry.expected_one_of_schema(exclude_claim_kinds=_RECORD_BOUND_CLAIMS),
len(self._checklist_items(task_id)),
)
def feature_plan_schema(self, task_id: str) -> dict[str, Any]:
"""Return the plan tool schema bound to the persisted planning state.
A model is allowed to choose node content, but it must not guess the
immutable lineage identifiers of a plan revision. Binding those values
as enums prevents a requirements-contract hash (or a stale plan hash)
from being mistaken for ``parent_plan_hash``.
"""
schema = FeaturePlan.model_json_schema()
properties = schema.get("properties") if isinstance(schema.get("properties"), dict) else {}
definitions = schema.get("$defs") if isinstance(schema.get("$defs"), dict) else {}
node = definitions.get("FeatureNode") if isinstance(definitions, dict) else None
node_properties = node.get("properties") if isinstance(node, dict) else None
atomic = node_properties.get("atomic_id") if isinstance(node_properties, dict) else None
if isinstance(atomic, dict):
atomic["enum"] = list(self.atomic_ids())
state = self.repository.get_state(task_id)
previous: FeaturePlan | None = None
if state is not None and state.feature_plan_path:
raw = self.artifacts.read_json(task_id, state.feature_plan_path)
try:
previous = FeaturePlan.model_validate(raw)
except ValueError:
previous = None
parent_hash = plan_hash(previous) if previous is not None else ""
replacements = sorted(self._required_replacements(previous, task_id)) if previous is not None else []
parent = properties.get("parent_plan_hash") if isinstance(properties, dict) else None
if isinstance(parent, dict):
parent["enum"] = [parent_hash]
replaced = properties.get("replaces_node_ids") if isinstance(properties, dict) else None
if isinstance(replaced, dict):
replaced.update({
"type": "array",
"uniqueItems": True,
"minItems": len(replacements),
"maxItems": len(replacements),
"items": {"enum": replacements},
})
contract = self.artifacts.read_requirements_contract(
task_id,
state.requirements_contract_path if state is not None else "",
) or {}
deterministic_claim_ids = sorted(
str(claim.get("claim_id") or "")
for requirement in contract.get("requirements") or ()
if isinstance(requirement, dict)
for claim in requirement.get("acceptance_claims") or ()
if isinstance(claim, dict)
and claim.get("verification_mode") == "deterministic"
and isinstance(claim.get("claim_id"), str)
and claim.get("claim_id")
)
visual_claim_ids = sorted(
str(claim.get("claim_id") or "")
for requirement in contract.get("requirements") or ()
if isinstance(requirement, dict)
for claim in requirement.get("acceptance_claims") or ()
if isinstance(claim, dict)
and claim.get("verification_mode") != "deterministic"
and isinstance(claim.get("claim_id"), str)
and claim.get("claim_id")
)
node_claim_ids = node_properties.get("claim_ids") if isinstance(node_properties, dict) else None
if isinstance(node_claim_ids, dict):
# Authors occasionally repeat a visual claim on the node that
# creates the feature as well as in final_claim_ids. Accept that
# harmless reference at the tool boundary; submit_feature_plan()
# removes it before the immutable DAG is validated and written.
node_claim_ids["items"] = {"enum": [*deterministic_claim_ids, *visual_claim_ids]}
final_claim_ids = properties.get("final_claim_ids") if isinstance(properties, dict) else None
if isinstance(final_claim_ids, dict):
final_claim_ids["items"] = {"enum": visual_claim_ids}
final_claim_ids["maxItems"] = len(visual_claim_ids)
return schema
def submit_requirements_document(self, task_id: str, document: MarkdownDocument, *, invocation_id: str) -> Accepted | Rejected:
return self._write_document(task_id, document, invocation_id=invocation_id, phase=TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT, path="requirements.md", event="requirements_document_written", validator=self._validate_requirements_document)
def submit_completion_target(self, task_id: str, document: MarkdownDocument, *, invocation_id: str) -> Accepted | Rejected:
return self._write_document(task_id, document, invocation_id=invocation_id, phase=TaskPhase.DRAFTING_COMPLETION_TARGET, path="completion-target.md", event="completion_target_written", validator=self._validate_completion_target)
def submit_modeling_plan(self, task_id: str, document: MarkdownDocument, *, invocation_id: str) -> Accepted | Rejected:
# Compatibility shim for callers compiled against v3.1. The v3.2
# coordinator never offers this method to an LLM.
return self._write_document(task_id, document, invocation_id=invocation_id, phase=TaskPhase.COMPILING_FEATURE_PLAN, path="modeling-plan.md", event="modeling_plan_written", validator=self._validate_modeling_plan)
def submit_feature_plan(self, task_id: str, plan: FeaturePlan, *, invocation_id: str) -> Accepted | Rejected:
"""Freeze a validated initial plan or full subgraph plan revision."""
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 not in {TaskPhase.COMPILING_FEATURE_PLAN, TaskPhase.REPLANNING_FEATURE_SUBGRAPH}:
return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "A feature plan is not expected in the current workflow phase."))
contract = self.artifacts.read_requirements_contract(task_id, state.requirements_contract_path)
if not isinstance(contract, dict):
return Rejected(WorkflowError(ErrorCode.REQUIREMENTS_SPEC_INVALID, "Requirements contract is unavailable for feature planning."))
plan = self._normalize_feature_plan_visual_references(plan, contract)
plan = self._assign_unowned_global_health_claims(plan, contract)
previous: FeaturePlan | None = None
completed: dict[str, str] = {}
if state.feature_plan_path:
raw = self.artifacts.read_json(task_id, state.feature_plan_path)
try:
previous = FeaturePlan.model_validate(raw)
except ValueError:
return Rejected(WorkflowError(ErrorCode.STORAGE_FAILURE, "The active feature plan artifact is invalid.", retryable=True))
completed = FeatureScheduler(previous, self.repository.ledger_events(task_id)).completed_node_hashes()
required_replacements = self._required_replacements(previous, task_id) if previous is not None else set()
errors = validate_feature_plan(plan, contract, self.atomic_ids(), previous_plan=previous, completed_node_hashes=completed, required_replacements=required_replacements)
if errors:
return Rejected(WorkflowError(ErrorCode.REQUIREMENTS_SPEC_INVALID, "Feature plan does not satisfy the frozen contract.", field_errors=tuple(errors)))
digest = plan_hash(plan)
path = f"plans/feature-plan-{digest}.json"
event = "feature_plan_written" if previous is None else "feature_plan_revised"
invocation = self.repository.begin_invocation(task_id, invocation_id, self._key(task_id, event, state.working_head, plan.model_dump(mode="json")))
if invocation.status == "finished" and invocation.result is not None:
return self._restore(invocation.result)
try:
written = self.artifacts.write_json_once(task_id, path, plan.model_dump(mode="json"))
except OSError as error:
return self._park_for_storage_retry(state, str(error))
next_state = transition(state, event, feature_plan_path=written, feature_plan_hash=digest)
result = Accepted({"phase": next_state.phase.value, "path": written, "plan_hash": digest, "node_count": len(plan.nodes)})
events: list[dict[str, Any]] = [{
"event": event,
"invocation_id": invocation_id,
"plan_path": written,
"plan_hash": digest,
"parent_plan_hash": plan.parent_plan_hash,
"replaces_node_ids": plan.replaces_node_ids,
}]
if previous is not None:
old_nodes = {node.node_id: node for node in previous.nodes}
old_hash = plan_hash(previous)
events.extend({
"event": "feature_node_invalidated",
"node_id": node_id,
"node_hash": node_hash(old_nodes[node_id]),
"plan_hash": old_hash,
"replacement_plan_hash": digest,
} for node_id in plan.replaces_node_ids)
if not self._commit(next_state, events, invocation, result):
return Rejected(self._stale())
return result
@staticmethod
def _normalize_feature_plan_visual_references(plan: FeaturePlan, contract: dict[str, Any]) -> FeaturePlan:
"""Drop non-owning visual references from feature nodes.
A node's ``claim_ids`` drive synchronous deterministic acceptance.
Visual claims are owned solely by ``final_claim_ids`` and have no
node-local verifier. Retaining a repeated visual ID therefore adds
no behavior and turns an otherwise valid plan into a schema retry.
The contract validation below still requires every visual claim to be
present exactly once in ``final_claim_ids``.
"""
visual_claim_ids = {
str(claim.get("claim_id") or "")
for requirement in contract.get("requirements") or ()
if isinstance(requirement, dict)
for claim in requirement.get("acceptance_claims") or ()
if isinstance(claim, dict)
and claim.get("verification_mode") != "deterministic"
and isinstance(claim.get("claim_id"), str)
}
if not visual_claim_ids or not any(
claim_id in visual_claim_ids
for node in plan.nodes
for claim_id in node.claim_ids
):
return plan
normalized = plan.model_copy(deep=True)
for node in normalized.nodes:
node.claim_ids = [claim_id for claim_id in node.claim_ids if claim_id not in visual_claim_ids]
return normalized
@staticmethod
def _assign_unowned_global_health_claims(plan: FeaturePlan, contract: dict[str, Any]) -> FeaturePlan:
"""Bind global solid-health claims to the unique root body feature.
``single_connected_body`` and ``solid_count_equals`` are checked as
global health on every feature checkpoint. When a plan has exactly
one root additive feature, their node owner is consequently
determined without choosing any geometry strategy. This prevents an
otherwise complete plan from failing merely because an author omitted
the redundant ownership annotation.
"""
claims = {
str(claim.get("claim_id") or ""): str(claim.get("claim_kind") or "")
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("claim_id"), str)
}
assigned = {claim_id for node in plan.nodes for claim_id in node.claim_ids}
unowned = [
claim_id for claim_id, claim_kind in claims.items()
if claim_id not in assigned and claim_kind in {"single_connected_body", "solid_count_equals"}
]
roots = [
node for node in plan.nodes
if not node.depends_on and node.atomic_id in {"extrude_add_blind", "extrude_add_two_sided", "revolve_add", "sphere_add"}
]
if not unowned or len(roots) != 1:
return plan
normalized = plan.model_copy(deep=True)
root_id = roots[0].node_id
for node in normalized.nodes:
if node.node_id == root_id:
node.claim_ids = [*node.claim_ids, *unowned]
break
return normalized
def _required_replacements(self, plan: FeaturePlan, task_id: str) -> set[str]:
scheduler = FeatureScheduler(plan, self.repository.ledger_events(task_id))
statuses = scheduler.statuses()
failed = {node_id for node_id, status in statuses.items() if status == "failed"}
if not failed:
return set()
children: dict[str, set[str]] = {node.node_id: set() for node in plan.nodes}
for node in plan.nodes:
for dependency in node.depends_on:
children.setdefault(dependency, set()).add(node.node_id)
result = set(failed)
pending = list(failed)
while pending:
current = pending.pop()
for child in children.get(current, set()):
if statuses.get(child) != "done" and child not in result:
result.add(child)
pending.append(child)
return result
def submit_compiled_spec(self, task_id: str, output: CompiledRequirementsSpec, *, 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.COMPILING_REQUIREMENTS:
return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "Requirements compilation is not expected in the current workflow phase."))
targets = self._checklist_items(task_id)
if len(output.requirements) != len(targets):
return Rejected(WorkflowError(ErrorCode.REQUIREMENTS_SPEC_INVALID, "The compiled requirements must contain exactly one entry for every frozen completion target.", field_errors=({"path": "/requirements", "message": f"Expected {len(targets)} entries, received {len(output.requirements)}."},)))
normalized_output, compiler_warnings = self._normalize_compiled_spec(output, targets)
field_errors = [
*self._claim_errors(normalized_output),
*self._relationship_claim_errors(normalized_output, targets),
]
if field_errors:
return Rejected(WorkflowError(ErrorCode.REQUIREMENTS_SPEC_INVALID, "Requirements compilation contains an unreadable or non-executable acceptance target.", field_errors=tuple(field_errors)))
invocation = self.repository.begin_invocation(task_id, invocation_id, self._key(task_id, "requirements_compilation", state.working_head, normalized_output.model_dump(mode="json")))
if invocation.status == "finished" and invocation.result is not None:
return self._restore(invocation.result)
source_ids = list(self.artifacts.read_source_index(task_id))
observation = self.artifacts.read_json(task_id, "documents/image-observation.json") or {}
warnings = [
*[str(value) for value in observation.get("uncertainties") or () if str(value)],
*compiler_warnings,
]
requirements: list[dict[str, Any]] = []
claim_position = 1
for position, (target, compiled) in enumerate(zip(targets, normalized_output.requirements, strict=True), 1):
claims: list[dict[str, Any]] = []
for claim in compiled.acceptance_claims:
definition = self.registry.definition(claim.claim_kind)
claims.append({"claim_id": f"claim_{claim_position:03d}", "claim_kind": claim.claim_kind, "expected": claim.expected, "verification_mode": "deterministic" if definition.deterministic else "visual"})
claim_position += 1
requirements.append({"requirement_id": f"req_{position:03d}", "source_ids": source_ids, "statement": target, "assumptions": list(compiled.assumptions), "acceptance_claims": claims})
spec = {"schema_version": "cad.requirements-spec.v2", "requirements_document_path": state.requirements_document_path, "completion_target_path": state.completion_target_path, "image_observation_path": "documents/image-observation.json" if observation else "", "requirements": [item.model_dump(mode="json") for item in normalized_output.requirements]}
contract = {"schema_version": "cad.requirements-contract.v3.2", "task_id": task_id, "requirements_document_path": state.requirements_document_path, "completion_target_path": state.completion_target_path, "requirements": requirements, "verification_warnings": warnings}
contract["contract_hash"] = sha256(json.dumps(contract, ensure_ascii=True, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
try:
spec_path = self.artifacts.write_json_once(task_id, "documents/requirements-spec.json", spec)
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, str(error))
next_state = transition(state, "requirements_compiled", requirements_spec_path=spec_path, requirements_contract_path=contract_path)
result = Accepted({"phase": next_state.phase.value, "spec_path": spec_path, "contract_path": contract_path, "target_count": len(targets)})
if not self._commit(next_state, [{"event": "requirements_compiled", "invocation_id": invocation_id, "contract_hash": contract["contract_hash"], "spec_path": spec_path, "contract_path": contract_path, "target_count": len(targets), "verification_warnings": warnings}], invocation, result):
return Rejected(self._stale())
return result
def write_completion_result(self, task_id: str, state: TaskState, *, claim_results: list[dict[str, Any]], review: dict[str, Any]) -> str:
contract = self.artifacts.read_requirements_contract(task_id, state.requirements_contract_path) or {}
by_id = {str(item.get("claim_id") or ""): item for item in claim_results if isinstance(item, dict)}
visual = iter(review.get("visual_claims") or ())
rows = ["# Completion Result", "", "## Checklist", ""]
for requirement in contract.get("requirements") or ():
if not isinstance(requirement, dict):
continue
statuses: list[str] = []
evidence: list[str] = []
for claim in requirement.get("acceptance_claims") or ():
if not isinstance(claim, dict):
continue
result = next(visual, {}) if claim.get("verification_mode") == "visual" else by_id.get(str(claim.get("claim_id") or ""), {})
statuses.append(str(result.get("status") or "unknown"))
value = result.get("evidence")
if value:
evidence.append(value if isinstance(value, str) else json.dumps(value, ensure_ascii=False, sort_keys=True))
rows.append(f"- [{'x' if statuses and all(value == 'pass' for value in statuses) else ' '}] {requirement.get('statement')}: {', '.join(statuses) or 'unknown'}")
rows.extend(f" - Evidence: {value}" for value in evidence)
return self.artifacts.write_text_once(task_id, "completion-result.md", "\n".join(rows).rstrip() + "\n")
def _write_document(self, task_id: str, document: MarkdownDocument, *, invocation_id: str, phase: TaskPhase, path: str, event: str, validator: Callable[[str], list[dict[str, 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 != phase:
return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "This document is not expected in the current workflow phase."))
errors = validator(document.markdown)
if errors:
return Rejected(WorkflowError(ErrorCode.REQUIREMENTS_SPEC_INVALID, "Frozen Markdown document does not satisfy its required template.", field_errors=tuple(errors)))
invocation = self.repository.begin_invocation(task_id, invocation_id, self._key(task_id, event, state.working_head, document.model_dump(mode="json")))
if invocation.status == "finished" and invocation.result is not None:
return self._restore(invocation.result)
try:
written = self.artifacts.write_text_once(task_id, path, document.markdown.strip() + "\n")
except OSError as error:
return self._park_for_storage_retry(state, str(error))
kwargs = {"requirements_document_path": written} if path == "requirements.md" else {"completion_target_path": written} if path == "completion-target.md" else {"modeling_plan_path": written}
next_state = transition(state, event, **kwargs)
result = Accepted({"phase": next_state.phase.value, "path": written})
if not self._commit(next_state, [{"event": event, "invocation_id": invocation_id, "path": written}], invocation, result):
return Rejected(self._stale())
return result
def _checklist_items(self, task_id: str) -> list[str]:
path = self.artifacts.task_dir(task_id) / "completion-target.md"
text = path.read_text(encoding="utf-8") if path.is_file() else ""
return [match.group(1).strip() for line in text.splitlines() if (match := _CHECKBOX.match(line))]
def _claim_errors(self, output: CompiledRequirementsSpec) -> list[dict[str, str]]:
errors: list[dict[str, str]] = []
for requirement_index, requirement in enumerate(output.requirements):
for claim_index, claim in enumerate(requirement.acceptance_claims):
try:
messages = self.registry.validate_expected(claim.claim_kind, claim.expected)
except ValueError:
messages = [{"path": "", "message": "VERIFIER_UNAVAILABLE"}]
errors.extend({"path": f"/requirements/{requirement_index}/acceptance_claims/{claim_index}/expected{item['path']}", "message": item["message"]} for item in messages)
return errors
@staticmethod
def _relationship_claim_errors(output: CompiledRequirementsSpec, targets: list[str]) -> list[dict[str, str]]:
"""Require explicit coverage for an unambiguous centered-bore target.
The compiler remains free to choose claims for open-ended CAD prose.
A checklist item that explicitly says a bore is centered/concentric is
different: dropping that relationship leaves a measurable user fact
with no acceptance owner. The service only checks its presence here;
the registry validates its numeric parameters independently.
"""
errors: list[dict[str, str]] = []
for index, (target, requirement) in enumerate(zip(targets, output.requirements, strict=True)):
lowered = target.casefold()
if not any(marker in lowered for marker in _CENTERED_BORE_MARKERS):
continue
if not any(marker in lowered for marker in _BORE_MARKERS):
continue
if any(
claim.claim_kind == "concentric_bore_to_outer_cylinder"
for claim in requirement.acceptance_claims
):
continue
errors.append({
"path": f"/requirements/{index}/acceptance_claims",
"message": "A centered or concentric bore requires concentric_bore_to_outer_cylinder coverage.",
})
return errors
def _normalize_compiled_spec(self, output: CompiledRequirementsSpec, targets: list[str]) -> tuple[CompiledRequirementsSpec, list[str]]:
normalized = output.model_copy(deep=True)
warnings: list[str] = []
for target, requirement in zip(targets, normalized.requirements, strict=True):
normalized_claims = []
for claim in requirement.acceptance_claims:
if self._is_slot_misclassified_as_corner_bore_pattern(claim, target):
warnings.append(
f"rectangular_corner_through_bore_pattern for checklist item '{target}' describes an obround slot, not four circular bores, so it was compiled as visual review."
)
claim.claim_kind = "visual"
claim.expected = {"description": target[:360]}
normalized_claims.append(claim)
continue
if self._is_unbacked_coaxial_bore_group(normalized, claim):
warnings.append(
f"coaxial_through_bore_group for checklist item '{target}' has no matching multi-bore target, so it was compiled as visual review. "
"Use concentric_bore_to_outer_cylinder for one central bore and one outer cylinder."
)
claim.claim_kind = "visual"
claim.expected = {"description": target[:360]}
normalized_claims.append(claim)
continue
if claim.claim_kind in _RECORD_BOUND_CLAIMS:
warnings.append(
f"{claim.claim_kind} verifier for checklist item '{target}' requires server-bound topology records, so it was compiled as visual review."
)
claim.claim_kind = "visual"
claim.expected = {"description": target[:360]}
normalized_claims.append(claim)
continue
if self._is_local_cylindrical_span_bbox(requirement.acceptance_claims, claim, target):
self._move_bbox_z_to_outer_cylindrical_span(requirement.acceptance_claims, claim)
warnings.append(
f"Global bbox Z verifier for checklist item '{target}' was omitted because the target describes a local cylindrical span, not the finished part envelope."
)
continue
claim.expected = self.registry.normalize_expected(claim.claim_kind, claim.expected)
normalized_claims.append(claim)
requirement.acceptance_claims = normalized_claims or [AcceptanceClaimInput.model_validate({
"claim_kind": "visual",
"expected": {"description": target[:360]},
})]
self._derive_centered_bore_claims(normalized, targets)
return normalized, list(dict.fromkeys(warnings))
@staticmethod
def _is_slot_misclassified_as_corner_bore_pattern(claim: Any, target: str) -> bool:
"""Keep a circular-hole verifier from accepting or rejecting a slot.
``rectangular_corner_through_bore_pattern`` measures four complete
cylindrical bores at equal edge offsets. An obround slot has two arc
ends and straight flanks; treating its stated length as an edge offset
produces an unsatisfiable contract even when the CAD is correct.
"""
return (
getattr(claim, "claim_kind", "") == "rectangular_corner_through_bore_pattern"
and any(marker in target.casefold() for marker in _OBROUND_SLOT_MARKERS)
)
@staticmethod
def _derive_centered_bore_claims(output: CompiledRequirementsSpec, targets: list[str]) -> None:
"""Attach a measurable concentricity claim when its inputs are frozen.
The requirements compiler receives a Markdown checklist, not runtime
geometry IDs. Once it has already compiled an external cylindrical
diameter and an explicitly centred bore diameter, their relationship
is a service-owned mechanical consequence. Requiring an author to
remember the internal verifier name makes a complete user request
fail for a bookkeeping omission rather than a CAD decision.
"""
outer_diameters: list[float] = []
for requirement in output.requirements:
for claim in requirement.acceptance_claims:
expected = claim.expected
diameter = expected.get("diameter_mm") if isinstance(expected, dict) else None
if claim.claim_kind == "outer_cylindrical_surface" and isinstance(diameter, (int, float)) and float(diameter) > 0:
outer_diameters.append(float(diameter))
if not outer_diameters:
return
outer_diameter = max(outer_diameters)
bore_claim_kinds = frozenset({"through_cylindrical_bore", "cylindrical_bore", "cylindrical_bore_depth"})
for target, requirement in zip(targets, output.requirements, strict=True):
lowered = target.casefold()
if not any(marker in lowered for marker in _CENTERED_BORE_MARKERS):
continue
if not any(marker in lowered for marker in _BORE_MARKERS):
continue
if any(claim.claim_kind == "concentric_bore_to_outer_cylinder" for claim in requirement.acceptance_claims):
continue
bore_diameter = next((
float(claim.expected["diameter_mm"])
for claim in requirement.acceptance_claims
if claim.claim_kind in bore_claim_kinds
and isinstance(claim.expected, dict)
and isinstance(claim.expected.get("diameter_mm"), (int, float))
and float(claim.expected["diameter_mm"]) > 0
), None)
if bore_diameter is None:
continue
requirement.acceptance_claims.append(AcceptanceClaimInput.model_validate({
"claim_kind": "concentric_bore_to_outer_cylinder",
"expected": {
"bore_diameter_mm": bore_diameter,
"outer_diameter_mm": outer_diameter,
"tolerance_mm": 0.01,
},
}))
@staticmethod
def _is_unbacked_coaxial_bore_group(output: CompiledRequirementsSpec, claim: Any) -> bool:
"""Reject a bore-group verifier when the contract has no such group.
``coaxial_through_bore_group`` measures multiple inner bores of one
diameter. It cannot prove a lone central bore is concentric with an
external cylindrical wall. This is a mechanical consistency check:
some through-bore claim must request at least the group count.
"""
if getattr(claim, "claim_kind", "") != "coaxial_through_bore_group":
return False
expected = getattr(claim, "expected", {})
if not isinstance(expected, dict):
return True
diameter = expected.get("diameter_mm")
count = expected.get("count")
if not isinstance(diameter, (int, float)) or not isinstance(count, int):
return True
for requirement in output.requirements:
for candidate in requirement.acceptance_claims:
candidate_expected = getattr(candidate, "expected", {})
if (
getattr(candidate, "claim_kind", "") == "through_cylindrical_bore"
and isinstance(candidate_expected, dict)
and isinstance(candidate_expected.get("diameter_mm"), (int, float))
and isinstance(candidate_expected.get("count"), int)
and abs(float(candidate_expected["diameter_mm"]) - float(diameter)) <= 1e-9
and int(candidate_expected["count"]) >= count
):
return False
return True
@staticmethod
def _is_local_cylindrical_span_bbox(claims: list[Any], claim: Any, target: str) -> bool:
if claim.claim_kind != "bbox_dimension_mm" or claim.expected.get("axis") != "z":
return False
if RequirementsCommandHandler._target_describes_finished_envelope(target):
return False
return any(
getattr(item, "claim_kind", "") == "outer_cylindrical_surface"
for item in claims
)
@staticmethod
def _target_describes_finished_envelope(target: str) -> bool:
lowered = target.lower()
return any(token in lowered for token in (
"overall",
"total",
"finished part",
"entire part",
"whole part",
"bounding box",
"envelope",
"总",
"整体",
"成品",
"全高",
"包围盒",
))
@staticmethod
def _move_bbox_z_to_outer_cylindrical_span(claims: list[Any], bbox_claim: Any) -> None:
value = bbox_claim.expected.get("value")
if not isinstance(value, (int, float)):
return
for item in claims:
if getattr(item, "claim_kind", "") != "outer_cylindrical_surface":
continue
expected = getattr(item, "expected", None)
if not isinstance(expected, dict) or "axial_span_mm" in expected:
continue
expected["axial_span_mm"] = value
if "tolerance_mm" not in expected and isinstance(bbox_claim.expected.get("tolerance_mm"), (int, float)):
expected["tolerance_mm"] = bbox_claim.expected["tolerance_mm"]
return
@staticmethod
def _validate_requirements_document(markdown: str) -> list[dict[str, str]]:
# Markdown is a human-facing semantic artifact. Its content is frozen
# verbatim and is not executable, so headings are guidance for the
# author rather than a server-enforced protocol.
return []
@staticmethod
def _validate_completion_target(markdown: str) -> list[dict[str, str]]:
values = [match.group(1).strip() for line in markdown.splitlines() if (match := _CHECKBOX.match(line))]
errors: list[dict[str, str]] = []
if not values:
errors.append({"path": "/markdown", "message": "Completion target requires at least one unchecked checklist item."})
if len(values) != len(set(values)):
errors.append({"path": "/markdown", "message": "Completion checklist items must be unique."})
return errors
@staticmethod
def _validate_modeling_plan(markdown: str) -> list[dict[str, str]]:
return []
def _replay(self, task_id: str, invocation_id: str) -> Accepted | None:
invocation = self.repository.get_invocation(task_id, invocation_id)
return self._restore(invocation.result) if invocation and invocation.status == "finished" and invocation.result else None
@staticmethod
def _restore(payload: dict[str, Any]) -> Accepted:
return Accepted(payload.get("payload") if isinstance(payload.get("payload"), dict) else payload)
def _commit(self, state: TaskState, events: list[dict[str, Any]], invocation: Any, result: Accepted) -> bool:
return self.repository.compare_and_swap(state, events=events, invocation_id=invocation.invocation_id, invocation_result={"result_type": "accepted", "payload": result.payload})
@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()).hexdigest()
@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, message: str) -> Rejected:
waiting = transition(state, "waiting_retry", error=ErrorCode.STORAGE_FAILURE)
self.repository.compare_and_swap(waiting, events=[{"event": "waiting_retry", "code": ErrorCode.STORAGE_FAILURE.value, "message": message[:1000]}])
return Rejected(WorkflowError(ErrorCode.STORAGE_FAILURE, "Requirements artifact storage is temporarily unavailable.", retryable=True))