297 lines
19 KiB
Python
297 lines
19 KiB
Python
"""Immutable Markdown-first requirements artifacts and compiled contracts."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from copy import deepcopy
|
|
from hashlib import sha256
|
|
import json
|
|
import re
|
|
from typing import Any, Callable
|
|
|
|
from app.cad_agent.application.llm_contracts import AcceptanceClaimInput, CompiledRequirementsSpec, MarkdownDocument, compiled_requirements_schema
|
|
from app.cad_agent.application.results import Accepted, Rejected
|
|
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
|
|
|
|
|
|
_CHECKBOX = re.compile(r"^\s*- \[ \]\s+(.+?)\s*$")
|
|
_RECORD_BOUND_CLAIMS = frozenset({"coaxial", "coplanar"})
|
|
|
|
|
|
class RequirementsCommandHandler:
|
|
"""Persist frozen documents and compile their checklist into a contract.
|
|
|
|
The model never names targets or internal objects during compilation. The
|
|
service derives those values strictly from the immutable checklist.
|
|
"""
|
|
|
|
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:
|
|
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)
|
|
return None if claims is None else {
|
|
"evaluation_only": True,
|
|
"required_claims": deepcopy(claims),
|
|
"known_validation_capability_gaps": deepcopy(self._evaluation_capability_gaps.get(task_id, [])),
|
|
}
|
|
|
|
@staticmethod
|
|
def document_schema() -> dict[str, Any]:
|
|
return MarkdownDocument.model_json_schema()
|
|
|
|
def compiler_schema(self, task_id: str) -> dict[str, Any]:
|
|
return compiled_requirements_schema(
|
|
self.registry.expected_one_of_schema(exclude_claim_kinds=_RECORD_BOUND_CLAIMS),
|
|
len(self._checklist_items(task_id)),
|
|
)
|
|
|
|
def submit_requirements_document(self, task_id: str, document: MarkdownDocument, *, invocation_id: str) -> Accepted | Rejected:
|
|
return self._write_document(task_id, document, invocation_id=invocation_id, phase=TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT, path="requirements.md", event="requirements_document_written", validator=self._validate_requirements_document)
|
|
|
|
def submit_completion_target(self, task_id: str, document: MarkdownDocument, *, invocation_id: str) -> Accepted | Rejected:
|
|
return self._write_document(task_id, document, invocation_id=invocation_id, phase=TaskPhase.DRAFTING_COMPLETION_TARGET, path="completion-target.md", event="completion_target_written", validator=self._validate_completion_target)
|
|
|
|
def submit_modeling_plan(self, task_id: str, document: MarkdownDocument, *, invocation_id: str) -> Accepted | Rejected:
|
|
return self._write_document(task_id, document, invocation_id=invocation_id, phase=TaskPhase.DRAFTING_MODELING_PLAN, path="modeling-plan.md", event="modeling_plan_written", validator=self._validate_modeling_plan)
|
|
|
|
def submit_compiled_spec(self, task_id: str, output: CompiledRequirementsSpec, *, 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.COMPILING_REQUIREMENTS:
|
|
return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "Requirements compilation is not expected in the current workflow phase."))
|
|
targets = self._checklist_items(task_id)
|
|
if len(output.requirements) != len(targets):
|
|
return Rejected(WorkflowError(ErrorCode.REQUIREMENTS_SPEC_INVALID, "The compiled requirements must contain exactly one entry for every frozen completion target.", field_errors=({"path": "/requirements", "message": f"Expected {len(targets)} entries, received {len(output.requirements)}."},)))
|
|
normalized_output, compiler_warnings = self._normalize_compiled_spec(output, targets)
|
|
field_errors = self._claim_errors(normalized_output)
|
|
if field_errors:
|
|
return Rejected(WorkflowError(ErrorCode.REQUIREMENTS_SPEC_INVALID, "Requirements compilation 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_compilation", state.working_head, normalized_output.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))
|
|
observation = self.artifacts.read_json(task_id, "documents/image-observation.json") or {}
|
|
warnings = [
|
|
*[str(value) for value in observation.get("uncertainties") or () if str(value)],
|
|
*compiler_warnings,
|
|
]
|
|
requirements: list[dict[str, Any]] = []
|
|
claim_position = 1
|
|
for position, (target, compiled) in enumerate(zip(targets, normalized_output.requirements, strict=True), 1):
|
|
claims: list[dict[str, Any]] = []
|
|
for claim in compiled.acceptance_claims:
|
|
definition = self.registry.definition(claim.claim_kind)
|
|
claims.append({"claim_id": f"claim_{claim_position:03d}", "claim_kind": claim.claim_kind, "expected": claim.expected, "verification_mode": "deterministic" if definition.deterministic else "visual"})
|
|
claim_position += 1
|
|
requirements.append({"requirement_id": f"req_{position:03d}", "source_ids": source_ids, "statement": target, "assumptions": list(compiled.assumptions), "acceptance_claims": claims})
|
|
spec = {"schema_version": "cad.requirements-spec.v2", "requirements_document_path": state.requirements_document_path, "completion_target_path": state.completion_target_path, "image_observation_path": "documents/image-observation.json" if observation else "", "requirements": [item.model_dump(mode="json") for item in normalized_output.requirements]}
|
|
contract = {"schema_version": "cad.requirements-contract.v3.1", "task_id": task_id, "requirements_document_path": state.requirements_document_path, "completion_target_path": state.completion_target_path, "requirements": requirements, "verification_warnings": warnings}
|
|
contract["contract_hash"] = sha256(json.dumps(contract, ensure_ascii=True, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
|
|
try:
|
|
spec_path = self.artifacts.write_json_once(task_id, "documents/requirements-spec.json", spec)
|
|
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_compiled", requirements_spec_path=spec_path, requirements_contract_path=contract_path)
|
|
result = Accepted({"phase": next_state.phase.value, "spec_path": spec_path, "contract_path": contract_path, "target_count": len(targets)})
|
|
if not self._commit(next_state, [{"event": "requirements_compiled", "invocation_id": invocation_id, "contract_hash": contract["contract_hash"], "spec_path": spec_path, "contract_path": contract_path, "target_count": len(targets), "verification_warnings": warnings}], invocation, result):
|
|
return Rejected(self._stale())
|
|
return result
|
|
|
|
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", "", "## Checklist", ""]
|
|
for requirement in contract.get("requirements") or ():
|
|
if not isinstance(requirement, dict):
|
|
continue
|
|
statuses: list[str] = []
|
|
evidence: list[str] = []
|
|
for claim in requirement.get("acceptance_claims") or ():
|
|
if not isinstance(claim, dict):
|
|
continue
|
|
result = next(visual, {}) if claim.get("verification_mode") == "visual" else by_id.get(str(claim.get("claim_id") or ""), {})
|
|
statuses.append(str(result.get("status") or "unknown"))
|
|
value = result.get("evidence")
|
|
if value:
|
|
evidence.append(value if isinstance(value, str) else json.dumps(value, ensure_ascii=False, sort_keys=True))
|
|
rows.append(f"- [{'x' if statuses and all(value == 'pass' for value in statuses) else ' '}] {requirement.get('statement')}: {', '.join(statuses) or 'unknown'}")
|
|
rows.extend(f" - Evidence: {value}" for value in evidence)
|
|
return self.artifacts.write_text_once(task_id, "completion-result.md", "\n".join(rows).rstrip() + "\n")
|
|
|
|
def _write_document(self, task_id: str, document: MarkdownDocument, *, invocation_id: str, phase: TaskPhase, path: str, event: str, validator: Callable[[str], list[dict[str, 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 != phase:
|
|
return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "This document is not expected in the current workflow phase."))
|
|
errors = validator(document.markdown)
|
|
if errors:
|
|
return Rejected(WorkflowError(ErrorCode.REQUIREMENTS_SPEC_INVALID, "Frozen Markdown document does not satisfy its required template.", field_errors=tuple(errors)))
|
|
invocation = self.repository.begin_invocation(task_id, invocation_id, self._key(task_id, event, state.working_head, document.model_dump(mode="json")))
|
|
if invocation.status == "finished" and invocation.result is not None:
|
|
return self._restore(invocation.result)
|
|
try:
|
|
written = self.artifacts.write_text_once(task_id, path, document.markdown.strip() + "\n")
|
|
except OSError as error:
|
|
return self._park_for_storage_retry(state, str(error))
|
|
kwargs = {"requirements_document_path": written} if path == "requirements.md" else {"completion_target_path": written} if path == "completion-target.md" else {"modeling_plan_path": written}
|
|
next_state = transition(state, event, **kwargs)
|
|
result = Accepted({"phase": next_state.phase.value, "path": written})
|
|
if not self._commit(next_state, [{"event": event, "invocation_id": invocation_id, "path": written}], invocation, result):
|
|
return Rejected(self._stale())
|
|
return result
|
|
|
|
def _checklist_items(self, task_id: str) -> list[str]:
|
|
path = self.artifacts.task_dir(task_id) / "completion-target.md"
|
|
text = path.read_text(encoding="utf-8") if path.is_file() else ""
|
|
return [match.group(1).strip() for line in text.splitlines() if (match := _CHECKBOX.match(line))]
|
|
|
|
def _claim_errors(self, output: CompiledRequirementsSpec) -> list[dict[str, str]]:
|
|
errors: list[dict[str, str]] = []
|
|
for requirement_index, requirement in enumerate(output.requirements):
|
|
for claim_index, claim in enumerate(requirement.acceptance_claims):
|
|
try:
|
|
messages = self.registry.validate_expected(claim.claim_kind, claim.expected)
|
|
except ValueError:
|
|
messages = [{"path": "", "message": "VERIFIER_UNAVAILABLE"}]
|
|
errors.extend({"path": f"/requirements/{requirement_index}/acceptance_claims/{claim_index}/expected{item['path']}", "message": item["message"]} for item in messages)
|
|
return errors
|
|
|
|
def _normalize_compiled_spec(self, output: CompiledRequirementsSpec, targets: list[str]) -> tuple[CompiledRequirementsSpec, list[str]]:
|
|
normalized = output.model_copy(deep=True)
|
|
warnings: list[str] = []
|
|
for target, requirement in zip(targets, normalized.requirements, strict=True):
|
|
normalized_claims = []
|
|
for claim in requirement.acceptance_claims:
|
|
if claim.claim_kind in _RECORD_BOUND_CLAIMS:
|
|
warnings.append(
|
|
f"{claim.claim_kind} verifier for checklist item '{target}' requires server-bound topology records, so it was compiled as visual review."
|
|
)
|
|
claim.claim_kind = "visual"
|
|
claim.expected = {"description": target[:360]}
|
|
normalized_claims.append(claim)
|
|
continue
|
|
if self._is_local_cylindrical_span_bbox(requirement.acceptance_claims, claim, target):
|
|
self._move_bbox_z_to_outer_cylindrical_span(requirement.acceptance_claims, claim)
|
|
warnings.append(
|
|
f"Global bbox Z verifier for checklist item '{target}' was omitted because the target describes a local cylindrical span, not the finished part envelope."
|
|
)
|
|
continue
|
|
claim.expected = self.registry.normalize_expected(claim.claim_kind, claim.expected)
|
|
normalized_claims.append(claim)
|
|
requirement.acceptance_claims = normalized_claims or [AcceptanceClaimInput.model_validate({
|
|
"claim_kind": "visual",
|
|
"expected": {"description": target[:360]},
|
|
})]
|
|
return normalized, list(dict.fromkeys(warnings))
|
|
|
|
@staticmethod
|
|
def _is_local_cylindrical_span_bbox(claims: list[Any], claim: Any, target: str) -> bool:
|
|
if claim.claim_kind != "bbox_dimension_mm" or claim.expected.get("axis") != "z":
|
|
return False
|
|
if RequirementsCommandHandler._target_describes_finished_envelope(target):
|
|
return False
|
|
return any(
|
|
getattr(item, "claim_kind", "") == "outer_cylindrical_surface"
|
|
for item in claims
|
|
)
|
|
|
|
@staticmethod
|
|
def _target_describes_finished_envelope(target: str) -> bool:
|
|
lowered = target.lower()
|
|
return any(token in lowered for token in (
|
|
"overall",
|
|
"total",
|
|
"finished part",
|
|
"entire part",
|
|
"whole part",
|
|
"bounding box",
|
|
"envelope",
|
|
"总",
|
|
"整体",
|
|
"成品",
|
|
"全高",
|
|
"包围盒",
|
|
))
|
|
|
|
@staticmethod
|
|
def _move_bbox_z_to_outer_cylindrical_span(claims: list[Any], bbox_claim: Any) -> None:
|
|
value = bbox_claim.expected.get("value")
|
|
if not isinstance(value, (int, float)):
|
|
return
|
|
for item in claims:
|
|
if getattr(item, "claim_kind", "") != "outer_cylindrical_surface":
|
|
continue
|
|
expected = getattr(item, "expected", None)
|
|
if not isinstance(expected, dict) or "axial_span_mm" in expected:
|
|
continue
|
|
expected["axial_span_mm"] = value
|
|
if "tolerance_mm" not in expected and isinstance(bbox_claim.expected.get("tolerance_mm"), (int, float)):
|
|
expected["tolerance_mm"] = bbox_claim.expected["tolerance_mm"]
|
|
return
|
|
|
|
@staticmethod
|
|
def _validate_requirements_document(markdown: str) -> list[dict[str, str]]:
|
|
# Markdown is a human-facing semantic artifact. Its content is frozen
|
|
# verbatim and is not executable, so headings are guidance for the
|
|
# author rather than a server-enforced protocol.
|
|
return []
|
|
|
|
@staticmethod
|
|
def _validate_completion_target(markdown: str) -> list[dict[str, str]]:
|
|
values = [match.group(1).strip() for line in markdown.splitlines() if (match := _CHECKBOX.match(line))]
|
|
errors: list[dict[str, str]] = []
|
|
if not values:
|
|
errors.append({"path": "/markdown", "message": "Completion target requires at least one unchecked checklist item."})
|
|
if len(values) != len(set(values)):
|
|
errors.append({"path": "/markdown", "message": "Completion checklist items must be unique."})
|
|
return errors
|
|
|
|
@staticmethod
|
|
def _validate_modeling_plan(markdown: str) -> list[dict[str, str]]:
|
|
return []
|
|
|
|
def _replay(self, task_id: str, invocation_id: str) -> Accepted | None:
|
|
invocation = self.repository.get_invocation(task_id, invocation_id)
|
|
return self._restore(invocation.result) if invocation and invocation.status == "finished" and invocation.result else None
|
|
|
|
@staticmethod
|
|
def _restore(payload: dict[str, Any]) -> Accepted:
|
|
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) -> bool:
|
|
return self.repository.compare_and_swap(state, events=events, invocation_id=invocation.invocation_id, invocation_result={"result_type": "accepted", "payload": 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()).hexdigest()
|
|
|
|
@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))
|