321 lines
16 KiB
Python
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))
|