"""One-pass requirements specification and server-owned contract artifacts.""" 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 ( RequirementsAuthorOutput, RequirementsClarification, RequirementsSpec, requirements_spec_schema, ) from app.cad_agent.application.results import Accepted, Rejected, Waiting 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 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: """Retain release-evaluation metadata without changing production decisions.""" 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) if claims is None: return None return { "evaluation_only": True, "required_claims": deepcopy(claims), "known_validation_capability_gaps": deepcopy(self._evaluation_capability_gaps.get(task_id, [])), } def spec_schema(self) -> dict[str, Any]: return requirements_spec_schema(self.registry.expected_one_of_schema()) def submit_spec(self, task_id: str, output: RequirementsAuthorOutput, *, 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.DRAFTING_REQUIREMENTS: return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "Requirements are not expected in the current workflow phase.")) value = output.root if isinstance(value, RequirementsClarification): return self._record_clarification(task_id, state, value, invocation_id=invocation_id) if not isinstance(value, RequirementsSpec): return Rejected(WorkflowError(ErrorCode.REQUIREMENTS_SPEC_INVALID, "Requirements output is not a supported specification.")) field_errors: list[dict[str, str]] = [] for requirement_index, requirement in enumerate(value.requirements): for claim_index, claim in enumerate(requirement.acceptance_claims): try: errors = self.registry.validate_expected(claim.claim_kind, claim.expected) except ValueError: errors = [{"path": "", "message": "VERIFIER_UNAVAILABLE"}] field_errors.extend({ "path": f"/requirements/{requirement_index}/acceptance_claims/{claim_index}/expected{error['path']}", "message": error["message"], } for error in errors) if field_errors: return Rejected(WorkflowError( ErrorCode.REQUIREMENTS_SPEC_INVALID, "Requirements specification 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_spec", state.working_head, value.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)) image_observation = self.artifacts.read_json(task_id, "documents/image-observation.json") or {} warnings = [str(item) for item in image_observation.get("uncertainties") or () if str(item)] requirements: list[dict[str, Any]] = [] claim_position = 1 for position, item in enumerate(value.requirements, 1): claims: list[dict[str, Any]] = [] for claim in item.acceptance_claims: deterministic = self.registry.definition(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}", "source_ids": source_ids, "statement": item.statement, "assumptions": list(item.assumptions), "acceptance_claims": claims, }) spec_payload = { "schema_version": "cad.requirements-spec.v1", "summary": value.summary, "assumptions": list(value.assumptions), "requirements": [item.model_dump(mode="json") for item in value.requirements], "image_observation_path": "documents/image-observation.json" if image_observation else "", } contract = { "schema_version": "cad.requirements-contract.v3", "task_id": task_id, "summary": value.summary, "assumptions": list(value.assumptions), "requirements": requirements, "verification_warnings": warnings, } contract["contract_hash"] = sha256(json.dumps(contract, ensure_ascii=True, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest() try: spec_path = self.artifacts.write_json_once(task_id, "documents/requirements-spec.json", spec_payload) 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_approved", requirements_spec_path=spec_path, requirements_contract_path=contract_path, clarification_path="", ) result = Accepted({"phase": next_state.phase.value, "contract_path": contract_path}) if not self._commit(next_state, [{ "event": "requirements_contract_frozen", "invocation_id": invocation_id, "contract_hash": contract["contract_hash"], "contract_path": contract_path, "requirement_count": len(requirements), "verification_warnings": warnings, }], invocation, result): return Rejected(self._stale()) self.ensure_rendered_contract_views(task_id, next_state) return result def ensure_rendered_contract_views(self, task_id: str, state: TaskState) -> None: 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_text_once(task_id, "requirements.md", self._requirements_markdown(contract)) target = self._completion_target_markdown(contract) self.artifacts.write_text_once(task_id, "completion-target.md", target) 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", "", f"Status: {'completed with risks' if contract.get('verification_warnings') else 'verified'}", ""] for requirement in contract.get("requirements") or (): if not isinstance(requirement, dict): continue rows.append(f"## {requirement.get('statement')}") for claim in requirement.get("acceptance_claims") or (): if not isinstance(claim, dict): continue if claim.get("verification_mode") == "visual": decision = next(visual, {}) status = str(decision.get("status") or "unknown") evidence = str(decision.get("evidence") or "") else: result = by_id.get(str(claim.get("claim_id") or ""), {}) status = str(result.get("status") or "unknown") evidence = json.dumps(result.get("evidence") or {}, ensure_ascii=False, sort_keys=True) rows.append(f"- [{'x' if status == 'pass' else ' '}] {claim.get('claim_kind')}: {status}") if evidence: rows.append(f" - Evidence: {evidence}") rows.append("") warnings = [str(item) for item in contract.get("verification_warnings") or () if str(item)] if warnings: rows.extend(["## Verification Warnings", "", *[f"- {item}" for item in warnings], ""]) return self.artifacts.write_text_once(task_id, "completion-result.md", "\n".join(rows).rstrip() + "\n") def _record_clarification( self, task_id: str, state: TaskState, clarification: RequirementsClarification, *, invocation_id: str, ) -> Waiting | Rejected: evidence = self.artifacts.read_source_requirements(task_id) observation = self.artifacts.read_json(task_id, "documents/image-observation.json") or {} evidence += "\n" + json.dumps(observation, ensure_ascii=False) missing = [quote for quote in clarification.source_quotes if quote not in evidence] if missing: return Rejected(WorkflowError( ErrorCode.REQUIREMENTS_SPEC_INVALID, "Clarification quotes must be copied from the user request or image observation.", field_errors=tuple({"path": "/source_quotes", "message": f"Unknown quote: {quote}"} for quote in missing), )) invocation = self.repository.begin_invocation( task_id, invocation_id, self._key(task_id, "requirements_clarification", state.working_head, clarification.model_dump(mode="json")), ) payload = {"schema_version": "cad.requirements-clarification.v1", **clarification.model_dump(mode="json")} try: path = self.artifacts.write_json_once(task_id, f"documents/requirements-clarification-{sha256(clarification.question.encode()).hexdigest()[:12]}.json", payload) except OSError as error: return self._park_for_storage_retry(state, str(error)) next_state = transition(state, "waiting_for_user", error=ErrorCode.WAITING_FOR_USER, clarification_path=path) result = Waiting(WorkflowError( ErrorCode.WAITING_FOR_USER, clarification.question, details={"questions": [clarification.question], "source_quotes": list(clarification.source_quotes)}, )) if not self._commit(next_state, [{ "event": "requirements_waiting_for_user", "invocation_id": invocation_id, "review_path": path, "message": clarification.question, "questions": [clarification.question], "source_quotes": list(clarification.source_quotes), }], invocation, result): return Rejected(self._stale()) return result 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) @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 {}), )) 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 | Waiting) -> bool: payload = {"result_type": "waiting", "error": result.error.payload()} if isinstance(result, Waiting) else {"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 _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() @staticmethod def _requirements_markdown(contract: dict[str, Any]) -> str: rows = ["# Requirements", "", str(contract.get("summary") or ""), ""] assumptions = [str(item) for item in contract.get("assumptions") or () if str(item)] if assumptions: rows.extend(["## Assumptions", "", *[f"- {item}" for item in assumptions], ""]) rows.extend(["## Requirements", ""]) for item in contract.get("requirements") or (): if not isinstance(item, dict): continue rows.append(f"- {item.get('statement')}") rows.extend(f" - Assumption: {value}" for value in item.get("assumptions") or ()) return "\n".join(rows).rstrip() + "\n" @staticmethod def _completion_target_markdown(contract: dict[str, Any]) -> str: rows = ["# Completion Target", ""] for requirement in contract.get("requirements") or (): if not isinstance(requirement, dict): continue rows.append(f"## {requirement.get('statement')}") for claim in requirement.get("acceptance_claims") or (): if isinstance(claim, dict): rows.append(f"- [ ] {claim.get('claim_kind')}: {json.dumps(claim.get('expected') or {}, ensure_ascii=False, sort_keys=True)}") rows.append("") return "\n".join(rows).rstrip() + "\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, 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))