Files
cdsl-cad/backend/app/cad_agent/application/requirements.py
T
2026-09-01 16:37:27 +08:00

321 lines
16 KiB
Python

"""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))