Files
cdsl-cad/backend/app/cad_agent/application/requirements_review.py
T
2026-09-01 14:10:23 +08:00

666 lines
35 KiB
Python

"""Requirements drafting/review handlers; Markdown is rendered, never parsed."""
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 (
RequirementsDraftBatch,
RequirementsPatchBatch,
RequirementsReview,
requirements_draft_schema,
requirements_patch_schema,
requirements_review_schema,
)
from app.cad_agent.application.normalization import NormalizationError, resolve_normalization
from app.cad_agent.application.results import Accepted, Rejected, Waiting
from app.cad_agent.domain.claim_matching import contains_expected
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
# This intentionally has no production configuration path. The live
# evaluator registers its fixture oracle per task before authoring.
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:
"""Register an external contract oracle for one live-evaluation task.
The source requirement remains evidence for normal production work;
this guarded fixture data exists only to keep an evaluation from
accepting a reviewer false positive before CAD execution starts.
"""
normalized: list[dict[str, Any]] = []
for item in required_claims:
claim_kind = item.get("claim_kind") if isinstance(item, dict) else None
expected = item.get("expected") if isinstance(item, dict) else None
if (
not isinstance(claim_kind, str)
or claim_kind not in self.registry.claim_kinds
or not isinstance(expected, dict)
):
raise ValueError("Evaluation contract oracle claims require claim_kind and expected object.")
# The frozen contract is production-valid and complete. Fixture
# expectations are intentionally partial so they can compare
# provider-chosen optional fields such as tolerances.
normalized.append({"claim_kind": claim_kind, "expected": deepcopy(expected)})
if not normalized:
raise ValueError("Evaluation contract oracle requires at least one claim.")
self._evaluation_contract_oracles[task_id] = normalized
gaps: list[dict[str, str]] = []
for gap in validation_capability_gaps or ():
gap_id = gap.get("id") if isinstance(gap, dict) else None
description = gap.get("description") if isinstance(gap, dict) else None
if not isinstance(gap_id, str) or not gap_id or not isinstance(description, str) or not description:
raise ValueError("Evaluation capability gaps require id and description.")
gaps.append({"id": gap_id, "description": description})
self._evaluation_capability_gaps[task_id] = gaps
def evaluation_review_context(self, task_id: str) -> dict[str, Any] | None:
"""Expose fixture-only known verifier gaps to the live reviewer."""
gaps = self._evaluation_capability_gaps.get(task_id)
if gaps is None:
return None
return {"evaluation_only": True, "known_validation_capability_gaps": deepcopy(gaps)}
def draft_schema(self, task_id: str) -> dict[str, Any]:
return requirements_draft_schema(self.registry.expected_one_of_schema(), list(self.artifacts.read_source_index(task_id)))
def patch_schema(self, task_id: str) -> dict[str, Any]:
state = self.repository.get_state(task_id)
draft = self._draft(state)
ids = [str(item.get("draft_id") or "") for item in draft.get("items") or () if isinstance(item, dict) and item.get("draft_id")]
return requirements_patch_schema(self.registry.expected_one_of_schema(), list(self.artifacts.read_source_index(task_id)), ids)
def review_schema(self, task_id: str) -> dict[str, Any]:
state = self.repository.get_state(task_id)
draft = self._draft(state)
draft_ids = [str(item.get("draft_id") or "") for item in draft.get("items") or () if isinstance(item, dict) and item.get("draft_id")]
source_ids = list(self.artifacts.read_source_index(task_id))
return requirements_review_schema(source_ids, draft_ids)
def submit_draft(self, task_id: str, batch: RequirementsDraftBatch, *, 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:
return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "Task does not exist."))
if state.phase != TaskPhase.DRAFTING_REQUIREMENTS:
return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "Requirements cannot be drafted in the current workflow phase."))
draft = self._draft(state)
source_index = self.artifacts.read_source_index(task_id)
current = [deepcopy(item) for item in draft.get("items") or () if isinstance(item, dict)]
errors = self._validate_items(batch.items, source_index, current)
if errors:
return Rejected(WorkflowError(ErrorCode.AUTHOR_FORMAT_INVALID, "Requirements draft batch failed canonical validation.", field_errors=tuple(errors)))
invocation = self.repository.begin_invocation(
task_id,
invocation_id,
self._key(task_id, "requirements_draft", state.working_head, batch.model_dump(mode="json")),
)
if invocation.status == "finished" and invocation.result is not None:
return self._restore(invocation.result)
start = len(current) + 1
for position, item in enumerate(batch.items, start):
current.append({"draft_id": f"draft_{position:03d}", **item.model_dump(mode="json")})
payload = {"schema_version": "cad.requirements-draft.v1", "revision": int(draft.get("revision") or 0) + 1, "items": current}
try:
artifact_path = self.artifacts.write_requirements_draft(task_id, payload, invocation_id=invocation_id)
except OSError as error:
return self._park_for_storage_retry(
state,
event="requirements_draft_storage_failure",
message=str(error),
)
next_state = transition(state, "draft_updated", requirements_draft_path=artifact_path)
result = Accepted({"draft_revision": payload["revision"], "draft_ids": [item["draft_id"] for item in current[-len(batch.items):]]})
if not self._commit(next_state, [{"event": "requirements_draft_updated", "invocation_id": invocation_id, "draft_revision": payload["revision"], "draft_path": artifact_path, "item_count": len(current)}], invocation, result):
return Rejected(self._stale())
return result
def patch_draft(self, task_id: str, batch: RequirementsPatchBatch, *, 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.DRAFTING_REQUIREMENTS:
return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "Requirements patches are not allowed in the current workflow phase."))
draft = self._draft(state)
items = [deepcopy(item) for item in draft.get("items") or () if isinstance(item, dict)]
by_id = {str(item.get("draft_id") or ""): item for item in items}
source_index = self.artifacts.read_source_index(task_id)
errors: list[dict[str, str]] = []
seen: set[str] = set()
for index, patch in enumerate(batch.patches):
if patch.target_draft_id in seen:
errors.append({"path": f"/patches/{index}/target_draft_id", "message": "A draft item may be patched only once per batch."})
seen.add(patch.target_draft_id)
if patch.target_draft_id not in by_id:
errors.append({"path": f"/patches/{index}/target_draft_id", "message": "Unknown current draft ID."})
if patch.item:
errors.extend(self._validate_items([patch.item], source_index, [item for item in items if item.get("draft_id") != patch.target_draft_id], prefix=f"/patches/{index}/item"))
if errors:
return Rejected(WorkflowError(ErrorCode.AUTHOR_FORMAT_INVALID, "Requirements patch batch failed canonical validation.", field_errors=tuple(errors)))
invocation = self.repository.begin_invocation(
task_id,
invocation_id,
self._key(task_id, "requirements_patch", state.working_head, batch.model_dump(mode="json")),
)
if invocation.status == "finished" and invocation.result is not None:
return self._restore(invocation.result)
remaining: list[dict[str, Any]] = []
patches = {patch.target_draft_id: patch for patch in batch.patches}
for item in items:
patch = patches.get(str(item["draft_id"]))
if patch is None:
remaining.append(item)
elif patch.op == "replace" and patch.item is not None:
remaining.append({"draft_id": item["draft_id"], **patch.item.model_dump(mode="json")})
payload = {"schema_version": "cad.requirements-draft.v1", "revision": int(draft.get("revision") or 0) + 1, "items": remaining}
try:
artifact_path = self.artifacts.write_requirements_draft(task_id, payload, invocation_id=invocation_id)
except OSError as error:
return self._park_for_storage_retry(
state,
event="requirements_patch_storage_failure",
message=str(error),
)
# A successful patch creates a new draft revision. The prior review
# describes the old revision and must not keep the author trapped in
# patch-only mode; it will be replaced after explicit re-finalization.
next_state = transition(
state,
"draft_updated",
requirements_draft_path=artifact_path,
requirements_review_path="",
)
result = Accepted({"draft_revision": payload["revision"], "item_count": len(remaining)})
if not self._commit(next_state, [{"event": "requirements_draft_patched", "invocation_id": invocation_id, "draft_revision": payload["revision"], "draft_path": artifact_path, "item_count": len(remaining)}], invocation, result):
return Rejected(self._stale())
return result
def finalize_draft(self, task_id: str, *, 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.DRAFTING_REQUIREMENTS:
return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "Requirements cannot be finalized in the current workflow phase."))
draft = self._draft(state)
items = [item for item in draft.get("items") or () if isinstance(item, dict)]
source_index = self.artifacts.read_source_index(task_id)
covered = {str(source) for item in items for source in item.get("source_ids") or ()}
missing = sorted(set(source_index) - covered)
if not items or missing:
return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "REQUIREMENTS_COVERAGE_INCOMPLETE", details={"missing_source_ids": missing}))
invocation = self.repository.begin_invocation(task_id, invocation_id, self._key(task_id, "requirements_finalize", state.working_head, {}))
if invocation.status == "finished" and invocation.result is not None:
return self._restore(invocation.result)
next_state = transition(state, "requirements_finalized")
result = Accepted({"phase": next_state.phase.value, "draft_revision": draft.get("revision", 0)})
if not self._commit(next_state, [{"event": "requirements_review_requested", "invocation_id": invocation_id, "draft_revision": draft.get("revision", 0)}], invocation, result):
return Rejected(self._stale())
return result
def record_review(self, task_id: str, review: RequirementsReview, *, 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.REVIEWING_REQUIREMENTS:
return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "Requirements review is not expected in the current workflow phase."))
draft = self._draft(state)
items = [item for item in draft.get("items") or () if isinstance(item, dict)]
source_ids = set(self.artifacts.read_source_index(task_id))
draft_ids = {str(item.get("draft_id") or "") for item in items}
errors = self._validate_review(review, source_ids, items, draft_ids)
if errors:
return Rejected(WorkflowError(ErrorCode.AUTHOR_FORMAT_INVALID, "Requirements review failed canonical validation.", field_errors=tuple(errors)))
invocation = self.repository.begin_invocation(
task_id,
invocation_id,
self._key(task_id, "requirements_review", state.working_head, review.model_dump(mode="json")),
)
if invocation.status == "finished" and invocation.result is not None:
return self._restore(invocation.result)
coverage = self._derive_coverage(source_ids, items)
normalizations: list[dict[str, Any]] = []
normalization_errors: list[dict[str, str]] = []
for index, finding in enumerate(review.findings):
if finding.finding_type != "derivable_conflict" or finding.normalization is None:
continue
try:
normalizations.append(resolve_normalization(
task_id=task_id,
draft_id=finding.draft_id,
source_ids=list(finding.source_ids),
description=finding.description,
value=finding.normalization.model_dump(mode="json"),
))
except NormalizationError as error:
normalization_errors.append({"path": f"/findings/{index}/normalization", "message": str(error)})
if normalization_errors:
return Rejected(WorkflowError(
ErrorCode.AUTHOR_FORMAT_INVALID,
"Requirements review supplied an invalid deterministic normalization.",
field_errors=tuple(normalization_errors),
))
warnings = [
finding.description
for finding in review.findings
if finding.finding_type == "verification_gap"
]
author_findings = [
finding for finding in review.findings
if finding.finding_type in {"missing_source_semantics", "claim_mismatch"}
or (
finding.finding_type == "verification_gap"
and not self._draft_has_visual_claim(items, finding.draft_id)
)
]
ambiguous = [finding for finding in review.findings if finding.finding_type == "ambiguous_conflict"]
decision = "waiting_for_user" if ambiguous else "revise" if author_findings else "freeze"
review_payload = {
"schema_version": "cad.requirements-review.v2",
"findings": [finding.model_dump(mode="json") for finding in review.findings],
"coverage": coverage,
"decision": decision,
"verification_warnings": warnings,
"applied_normalizations": normalizations,
}
try:
review_path = self.artifacts.write_requirements_review(task_id, review_payload, invocation_id=invocation_id)
except OSError as error:
return self._park_for_storage_retry(
state,
event="requirements_review_storage_failure",
message=str(error),
)
if ambiguous:
next_state = transition(state, "waiting_for_user", error=ErrorCode.WAITING_FOR_USER, requirements_review_path=review_path)
questions = [str(finding.question) for finding in ambiguous if finding.question]
message = "Requirements contain an ambiguity that cannot be resolved deterministically."
ambiguous_findings = [finding.model_dump(mode="json") for finding in ambiguous]
result = Waiting(WorkflowError(
ErrorCode.WAITING_FOR_USER,
message,
details={"questions": questions, "findings": ambiguous_findings},
))
if not self._commit(next_state, [{
"event": "requirements_waiting_for_user",
"invocation_id": invocation_id,
"review_path": review_path,
"message": message,
"questions": questions,
"findings": ambiguous_findings,
}], invocation, result):
return Rejected(self._stale())
return result
if author_findings:
next_state = transition(state, "requirements_revise", requirements_review_path=review_path)
result = Accepted({"phase": next_state.phase.value, "review": review_payload})
if not self._commit(next_state, [{"event": "requirements_review_revise", "invocation_id": invocation_id, "review_path": review_path}], invocation, result):
return Rejected(self._stale())
return result
if normalizations:
normalization_digest = sha256(
json.dumps(normalizations, ensure_ascii=True, sort_keys=True, separators=(",", ":")).encode("utf-8")
).hexdigest()[:12]
try:
normalization_path = self.artifacts.write_json_once(
task_id,
f"documents/requirements-normalizations-{normalization_digest}.json",
{
"schema_version": "cad.requirements-normalizations.v1",
"task_id": task_id,
"items": normalizations,
},
)
except OSError as error:
return self._park_for_storage_retry(
state,
event="requirements_normalization_storage_failure",
message=str(error),
)
else:
normalization_path = ""
contract = self._freeze_contract(
task_id,
items,
verification_warnings=warnings,
applied_normalizations=normalizations,
)
missing_claims = self._missing_evaluation_oracle_claims(task_id, contract)
if missing_claims:
oracle_payload = self._evaluation_oracle_failure(
task_id,
draft_revision=int(draft.get("revision") or 0),
reviewer_review_path=review_path,
draft_ids=sorted(draft_ids),
missing_claims=missing_claims,
)
oracle_digest = sha256(
json.dumps(oracle_payload, ensure_ascii=True, sort_keys=True, separators=(",", ":")).encode("utf-8")
).hexdigest()[:12]
try:
oracle_path = self.artifacts.write_json_once(
task_id,
f"documents/evaluation-contract-oracle-{oracle_digest}.json",
oracle_payload,
)
except OSError as error:
return self._park_for_storage_retry(
state,
event="evaluation_contract_oracle_storage_failure",
message=str(error),
)
next_state = transition(state, "requirements_revise", requirements_review_path=oracle_path)
result = Accepted({
"phase": next_state.phase.value,
"review": oracle_payload,
"reviewer_review_path": review_path,
"evaluation_oracle": {"missing_claims": missing_claims},
})
if not self._commit(next_state, [{
"event": "requirements_evaluation_oracle_revise",
"invocation_id": invocation_id,
"review_path": review_path,
"oracle_path": oracle_path,
"missing_claims": missing_claims,
}], invocation, result):
return Rejected(self._stale())
return result
try:
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,
event="requirements_contract_storage_failure",
message=str(error),
)
next_state = transition(
state,
"requirements_approved",
requirements_review_path=review_path,
requirements_contract_path=contract_path,
)
result = Accepted({
"phase": next_state.phase.value,
"contract_hash": contract["contract_hash"],
"verification_warnings": warnings,
"applied_normalizations": normalizations,
})
events = []
if normalizations:
events.append({
"event": "requirements_normalized",
"normalization_path": normalization_path,
"applied_normalizations": normalizations,
})
events.append({"event": "requirements_contract_frozen", "invocation_id": invocation_id, "contract_hash": contract["contract_hash"], "contract_path": contract_path, "review_path": review_path, "requirement_count": len(contract["requirements"]), "verification_warnings": warnings, "applied_normalizations": normalizations})
if not self._commit(next_state, events, invocation, result):
return Rejected(self._stale())
return result
def ensure_rendered_contract_views(self, task_id: str, state: TaskState) -> None:
"""Rebuild read-only contract views from the committed immutable path.
The SQLite transition is authoritative. Rendering convenience files
after its commit must never reclassify a frozen contract as an
internal authoring failure, so this idempotent operation is replayed
by the workflow until every view exists.
"""
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_json_once(task_id, "requirements-index.json", {
"schema_version": "cad.requirements-index.v1",
"requirements": [
{
"requirement_id": item["requirement_id"],
"claim_ids": [claim["claim_id"] for claim in item["acceptance_claims"]],
}
for item in contract.get("requirements") or ()
if isinstance(item, dict)
],
})
self.artifacts.write_text_once(task_id, "requirements.md", self._requirements_markdown(contract))
self.artifacts.write_text_once(task_id, "completion.md", self._completion_markdown(contract))
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)
def _finish(self, invocation_id: str, result: Accepted | Waiting) -> None:
if isinstance(result, Waiting):
payload = {"result_type": "waiting", "error": result.error.payload()}
else:
payload = {"result_type": "accepted", "payload": result.payload}
self.repository.finish_invocation(invocation_id, payload)
def _commit(self, state: TaskState, events: list[dict[str, Any]], invocation: Any, result: Accepted | Waiting) -> bool:
if isinstance(result, Waiting):
payload = {"result_type": "waiting", "error": result.error.payload()}
else:
payload = {"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 _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 {}),
))
value = payload.get("payload") if isinstance(payload.get("payload"), dict) else payload
return Accepted(value)
def _draft(self, state: TaskState | None) -> dict[str, Any]:
return self.artifacts.read_requirements_draft(state.task_id, state.requirements_draft_path) if state is not None else {"schema_version": "cad.requirements-draft.v1", "revision": 0, "items": []}
@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()
def _validate_items(self, inputs: list[Any], source_index: dict[str, str], existing: list[dict[str, Any]], *, prefix: str = "/items") -> list[dict[str, str]]:
errors: list[dict[str, str]] = []
seen = {(tuple(item.get("source_ids") or ()), str(item.get("statement") or "").casefold()) for item in existing}
for index, item in enumerate(inputs):
path = f"{prefix}/{index}"
if not set(item.source_ids).issubset(source_index):
errors.append({"path": f"{path}/source_ids", "message": "source_ids must come from the current source index."})
signature = (tuple(item.source_ids), item.statement.casefold())
if signature in seen:
errors.append({"path": f"{path}/statement", "message": "Duplicate requirement item."})
seen.add(signature)
for claim_index, claim in enumerate(item.acceptance_claims):
try:
claim_errors = self.registry.validate_expected(claim.claim_kind, claim.expected)
except ValueError:
errors.append({"path": f"{path}/acceptance_claims/{claim_index}/claim_kind", "message": "VERIFIER_UNAVAILABLE"})
continue
errors.extend({"path": f"{path}/acceptance_claims/{claim_index}/expected{error['path']}", "message": error["message"]} for error in claim_errors)
return errors
@staticmethod
def _validate_review(
review: RequirementsReview,
source_ids: set[str],
items: list[dict[str, Any]],
draft_ids: set[str],
) -> list[dict[str, str]]:
errors: list[dict[str, str]] = []
by_draft = {str(item.get("draft_id") or ""): item for item in items}
for index, finding in enumerate(review.findings):
if finding.draft_id not in draft_ids:
errors.append({"path": f"/findings/{index}/draft_id", "message": "Finding must reference a current draft item."})
continue
if not set(finding.source_ids).issubset(source_ids):
errors.append({"path": f"/findings/{index}/source_ids", "message": "Finding source_ids must come from the current source index."})
continue
cited = set(by_draft[finding.draft_id].get("source_ids") or ())
if not set(finding.source_ids).issubset(cited):
errors.append({"path": f"/findings/{index}/source_ids", "message": "Finding source_ids must be cited by its draft item."})
return errors
@staticmethod
def _derive_coverage(source_ids: set[str], items: list[dict[str, Any]]) -> list[dict[str, Any]]:
coverage = {source_id: [] for source_id in sorted(source_ids)}
for position, item in enumerate(items, 1):
requirement_id = f"req_{position:03d}"
for source_id in item.get("source_ids") or ():
if source_id in coverage:
coverage[source_id].append(requirement_id)
return [{"source_id": source_id, "requirement_ids": requirement_ids} for source_id, requirement_ids in coverage.items()]
@staticmethod
def _draft_has_visual_claim(items: list[dict[str, Any]], draft_id: str) -> bool:
return any(
claim.get("claim_kind") == "visual"
for item in items
if item.get("draft_id") == draft_id
for claim in item.get("acceptance_claims") or ()
if isinstance(claim, dict)
)
def _freeze_contract(
self,
task_id: str,
items: list[dict[str, Any]],
*,
verification_warnings: list[str],
applied_normalizations: list[dict[str, Any]],
) -> dict[str, Any]:
requirements: list[dict[str, Any]] = []
claim_position = 1
for position, item in enumerate(items, 1):
claims: list[dict[str, Any]] = []
for claim in item.get("acceptance_claims") or ():
deterministic = self.registry.definition(str(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}", "draft_id": item["draft_id"], "source_ids": item["source_ids"], "statement": item["statement"], "assumptions": item["assumptions"], "acceptance_claims": claims})
payload = {
"schema_version": "cad.requirements-contract.v2",
"task_id": task_id,
"requirements": requirements,
"verification_warnings": list(verification_warnings),
"applied_normalizations": deepcopy(applied_normalizations),
}
payload["contract_hash"] = sha256(json.dumps(payload, ensure_ascii=True, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest()
return payload
def _missing_evaluation_oracle_claims(self, task_id: str, contract: dict[str, Any]) -> list[dict[str, Any]]:
required = self._evaluation_contract_oracles.get(task_id)
if not required:
return []
actual = [
{"claim_kind": str(claim.get("claim_kind") or ""), "expected": claim.get("expected")}
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("expected"), dict)
]
return [
deepcopy(claim)
for claim in required
if not any(
actual_claim["claim_kind"] == claim["claim_kind"]
and contains_expected(actual_claim["expected"], claim["expected"])
for actual_claim in actual
)
]
@staticmethod
def _evaluation_oracle_failure(
task_id: str,
*,
draft_revision: int,
reviewer_review_path: str,
draft_ids: list[str],
missing_claims: list[dict[str, Any]],
) -> dict[str, Any]:
return {
"schema_version": "cad.requirements-evaluation-oracle.v1",
"task_id": task_id,
"evaluation_only": True,
"decision": "revise",
"draft_revision": draft_revision,
"reviewer_decision": "freeze",
"reviewer_review_path": reviewer_review_path,
"findings": [
{
"draft_id": draft_id,
"source_ids": [],
"finding_type": "missing_source_semantics",
"description": "The external live-evaluation contract oracle found missing executable acceptance claims.",
}
for draft_id in draft_ids
],
"missing_claims": missing_claims,
}
@staticmethod
def _requirements_markdown(contract: dict[str, Any]) -> str:
rows = ["# Requirements", ""]
for item in contract["requirements"]:
rows.append(f"- {item['requirement_id']}: {item['statement']}")
for assumption in item["assumptions"]:
rows.append(f" - Assumption: {assumption}")
return "\n".join(rows) + "\n"
@staticmethod
def _completion_markdown(contract: dict[str, Any]) -> str:
return "# Completion\n\n" + "\n".join(f"- [ ] {item['requirement_id']}: {item['statement']}" for item in contract["requirements"]) + "\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, *, event: str, message: str) -> Rejected:
waiting = transition(state, "waiting_retry", error=ErrorCode.STORAGE_FAILURE)
if not self.repository.compare_and_swap(waiting, events=[{
"event": event,
"code": ErrorCode.STORAGE_FAILURE.value,
"message": message[:1000],
}]):
return Rejected(self._stale())
return Rejected(WorkflowError(
ErrorCode.STORAGE_FAILURE,
"Requirements artifact storage is temporarily unavailable; the task can be resumed.",
retryable=True,
))