666 lines
35 KiB
Python
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,
|
|
))
|