"""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.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"}) 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) -> None: self.repository = repository self.artifacts = artifacts self.registry = registry 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 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: return self._write_document(task_id, document, invocation_id=invocation_id, phase=TaskPhase.DRAFTING_MODELING_PLAN, path="modeling-plan.md", event="modeling_plan_written", validator=self._validate_modeling_plan) 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) 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.1", "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 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 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]}, })] return normalized, list(dict.fromkeys(warnings)) @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))