优化流程
This commit is contained in:
@@ -1,26 +1,32 @@
|
||||
"""One-pass requirements specification and server-owned contract artifacts."""
|
||||
"""Immutable Markdown-first requirements artifacts and compiled contracts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
from hashlib import sha256
|
||||
import json
|
||||
from typing import Any
|
||||
import re
|
||||
from typing import Any, Callable
|
||||
|
||||
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.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
|
||||
@@ -28,283 +34,257 @@ class RequirementsCommandHandler:
|
||||
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."""
|
||||
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)
|
||||
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 {
|
||||
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, [])),
|
||||
}
|
||||
|
||||
def spec_schema(self) -> dict[str, Any]:
|
||||
return requirements_spec_schema(self.registry.expected_one_of_schema())
|
||||
@staticmethod
|
||||
def document_schema() -> dict[str, Any]:
|
||||
return MarkdownDocument.model_json_schema()
|
||||
|
||||
def submit_spec(self, task_id: str, output: RequirementsAuthorOutput, *, invocation_id: str) -> Accepted | Rejected | Waiting:
|
||||
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.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 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 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")),
|
||||
)
|
||||
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))
|
||||
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)]
|
||||
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, item in enumerate(value.requirements, 1):
|
||||
for position, (target, compiled) in enumerate(zip(targets, normalized_output.requirements, strict=True), 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",
|
||||
})
|
||||
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": 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()
|
||||
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_payload)
|
||||
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_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):
|
||||
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())
|
||||
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:
|
||||
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'}", ""]
|
||||
rows = ["# Completion Result", "", "## Checklist", ""]
|
||||
for requirement in contract.get("requirements") or ():
|
||||
if not isinstance(requirement, dict):
|
||||
continue
|
||||
rows.append(f"## {requirement.get('statement')}")
|
||||
statuses: list[str] = []
|
||||
evidence: list[str] = []
|
||||
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], ""])
|
||||
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 _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")}
|
||||
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:
|
||||
path = self.artifacts.write_json_once(task_id, f"documents/requirements-clarification-{sha256(clarification.question.encode()).hexdigest()[:12]}.json", payload)
|
||||
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))
|
||||
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):
|
||||
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 _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 _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 _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 {}),
|
||||
))
|
||||
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 | 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)
|
||||
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("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"
|
||||
return sha256(f"{task_id}|{kind}|{head}|{encoded}".encode()).hexdigest()
|
||||
|
||||
@staticmethod
|
||||
def _stale() -> WorkflowError:
|
||||
@@ -312,9 +292,5 @@ class RequirementsCommandHandler:
|
||||
|
||||
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],
|
||||
}])
|
||||
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))
|
||||
|
||||
Reference in New Issue
Block a user