671 lines
39 KiB
Python
671 lines
39 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.domain.feature_plan import FeaturePlan, FeatureScheduler, node_hash, plan_hash, validate_feature_plan
|
|
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"})
|
|
_CENTERED_BORE_MARKERS = ("centered", "concentric", "coaxial", "中心", "同心", "同轴")
|
|
_BORE_MARKERS = ("bore", "hole", "孔")
|
|
_OBROUND_SLOT_MARKERS = ("oblong", "slot", "slotted", "腰形", "长圆", "调节槽")
|
|
|
|
|
|
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, *, atomic_ids: Callable[[], tuple[str, ...]] | None = None) -> None:
|
|
self.repository = repository
|
|
self.artifacts = artifacts
|
|
self.registry = registry
|
|
self.atomic_ids = atomic_ids or (lambda: ())
|
|
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 feature_plan_schema(self, task_id: str) -> dict[str, Any]:
|
|
"""Return the plan tool schema bound to the persisted planning state.
|
|
|
|
A model is allowed to choose node content, but it must not guess the
|
|
immutable lineage identifiers of a plan revision. Binding those values
|
|
as enums prevents a requirements-contract hash (or a stale plan hash)
|
|
from being mistaken for ``parent_plan_hash``.
|
|
"""
|
|
schema = FeaturePlan.model_json_schema()
|
|
properties = schema.get("properties") if isinstance(schema.get("properties"), dict) else {}
|
|
definitions = schema.get("$defs") if isinstance(schema.get("$defs"), dict) else {}
|
|
node = definitions.get("FeatureNode") if isinstance(definitions, dict) else None
|
|
node_properties = node.get("properties") if isinstance(node, dict) else None
|
|
atomic = node_properties.get("atomic_id") if isinstance(node_properties, dict) else None
|
|
if isinstance(atomic, dict):
|
|
atomic["enum"] = list(self.atomic_ids())
|
|
state = self.repository.get_state(task_id)
|
|
previous: FeaturePlan | None = None
|
|
if state is not None and state.feature_plan_path:
|
|
raw = self.artifacts.read_json(task_id, state.feature_plan_path)
|
|
try:
|
|
previous = FeaturePlan.model_validate(raw)
|
|
except ValueError:
|
|
previous = None
|
|
parent_hash = plan_hash(previous) if previous is not None else ""
|
|
replacements = sorted(self._required_replacements(previous, task_id)) if previous is not None else []
|
|
parent = properties.get("parent_plan_hash") if isinstance(properties, dict) else None
|
|
if isinstance(parent, dict):
|
|
parent["enum"] = [parent_hash]
|
|
replaced = properties.get("replaces_node_ids") if isinstance(properties, dict) else None
|
|
if isinstance(replaced, dict):
|
|
replaced.update({
|
|
"type": "array",
|
|
"uniqueItems": True,
|
|
"minItems": len(replacements),
|
|
"maxItems": len(replacements),
|
|
"items": {"enum": replacements},
|
|
})
|
|
contract = self.artifacts.read_requirements_contract(
|
|
task_id,
|
|
state.requirements_contract_path if state is not None else "",
|
|
) or {}
|
|
deterministic_claim_ids = sorted(
|
|
str(claim.get("claim_id") or "")
|
|
for requirement in contract.get("requirements") or ()
|
|
if isinstance(requirement, dict)
|
|
for claim in requirement.get("acceptance_claims") or ()
|
|
if isinstance(claim, dict)
|
|
and claim.get("verification_mode") == "deterministic"
|
|
and isinstance(claim.get("claim_id"), str)
|
|
and claim.get("claim_id")
|
|
)
|
|
visual_claim_ids = sorted(
|
|
str(claim.get("claim_id") or "")
|
|
for requirement in contract.get("requirements") or ()
|
|
if isinstance(requirement, dict)
|
|
for claim in requirement.get("acceptance_claims") or ()
|
|
if isinstance(claim, dict)
|
|
and claim.get("verification_mode") != "deterministic"
|
|
and isinstance(claim.get("claim_id"), str)
|
|
and claim.get("claim_id")
|
|
)
|
|
node_claim_ids = node_properties.get("claim_ids") if isinstance(node_properties, dict) else None
|
|
if isinstance(node_claim_ids, dict):
|
|
# Authors occasionally repeat a visual claim on the node that
|
|
# creates the feature as well as in final_claim_ids. Accept that
|
|
# harmless reference at the tool boundary; submit_feature_plan()
|
|
# removes it before the immutable DAG is validated and written.
|
|
node_claim_ids["items"] = {"enum": [*deterministic_claim_ids, *visual_claim_ids]}
|
|
final_claim_ids = properties.get("final_claim_ids") if isinstance(properties, dict) else None
|
|
if isinstance(final_claim_ids, dict):
|
|
final_claim_ids["items"] = {"enum": visual_claim_ids}
|
|
final_claim_ids["maxItems"] = len(visual_claim_ids)
|
|
return schema
|
|
|
|
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:
|
|
# Compatibility shim for callers compiled against v3.1. The v3.2
|
|
# coordinator never offers this method to an LLM.
|
|
return self._write_document(task_id, document, invocation_id=invocation_id, phase=TaskPhase.COMPILING_FEATURE_PLAN, path="modeling-plan.md", event="modeling_plan_written", validator=self._validate_modeling_plan)
|
|
|
|
def submit_feature_plan(self, task_id: str, plan: FeaturePlan, *, invocation_id: str) -> Accepted | Rejected:
|
|
"""Freeze a validated initial plan or full subgraph plan revision."""
|
|
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 not in {TaskPhase.COMPILING_FEATURE_PLAN, TaskPhase.REPLANNING_FEATURE_SUBGRAPH}:
|
|
return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "A feature plan is not expected in the current workflow phase."))
|
|
contract = self.artifacts.read_requirements_contract(task_id, state.requirements_contract_path)
|
|
if not isinstance(contract, dict):
|
|
return Rejected(WorkflowError(ErrorCode.REQUIREMENTS_SPEC_INVALID, "Requirements contract is unavailable for feature planning."))
|
|
plan = self._normalize_feature_plan_visual_references(plan, contract)
|
|
plan = self._assign_unowned_global_health_claims(plan, contract)
|
|
previous: FeaturePlan | None = None
|
|
completed: dict[str, str] = {}
|
|
if state.feature_plan_path:
|
|
raw = self.artifacts.read_json(task_id, state.feature_plan_path)
|
|
try:
|
|
previous = FeaturePlan.model_validate(raw)
|
|
except ValueError:
|
|
return Rejected(WorkflowError(ErrorCode.STORAGE_FAILURE, "The active feature plan artifact is invalid.", retryable=True))
|
|
completed = FeatureScheduler(previous, self.repository.ledger_events(task_id)).completed_node_hashes()
|
|
required_replacements = self._required_replacements(previous, task_id) if previous is not None else set()
|
|
errors = validate_feature_plan(plan, contract, self.atomic_ids(), previous_plan=previous, completed_node_hashes=completed, required_replacements=required_replacements)
|
|
if errors:
|
|
return Rejected(WorkflowError(ErrorCode.REQUIREMENTS_SPEC_INVALID, "Feature plan does not satisfy the frozen contract.", field_errors=tuple(errors)))
|
|
digest = plan_hash(plan)
|
|
path = f"plans/feature-plan-{digest}.json"
|
|
event = "feature_plan_written" if previous is None else "feature_plan_revised"
|
|
invocation = self.repository.begin_invocation(task_id, invocation_id, self._key(task_id, event, state.working_head, plan.model_dump(mode="json")))
|
|
if invocation.status == "finished" and invocation.result is not None:
|
|
return self._restore(invocation.result)
|
|
try:
|
|
written = self.artifacts.write_json_once(task_id, path, plan.model_dump(mode="json"))
|
|
except OSError as error:
|
|
return self._park_for_storage_retry(state, str(error))
|
|
next_state = transition(state, event, feature_plan_path=written, feature_plan_hash=digest)
|
|
result = Accepted({"phase": next_state.phase.value, "path": written, "plan_hash": digest, "node_count": len(plan.nodes)})
|
|
events: list[dict[str, Any]] = [{
|
|
"event": event,
|
|
"invocation_id": invocation_id,
|
|
"plan_path": written,
|
|
"plan_hash": digest,
|
|
"parent_plan_hash": plan.parent_plan_hash,
|
|
"replaces_node_ids": plan.replaces_node_ids,
|
|
}]
|
|
if previous is not None:
|
|
old_nodes = {node.node_id: node for node in previous.nodes}
|
|
old_hash = plan_hash(previous)
|
|
events.extend({
|
|
"event": "feature_node_invalidated",
|
|
"node_id": node_id,
|
|
"node_hash": node_hash(old_nodes[node_id]),
|
|
"plan_hash": old_hash,
|
|
"replacement_plan_hash": digest,
|
|
} for node_id in plan.replaces_node_ids)
|
|
if not self._commit(next_state, events, invocation, result):
|
|
return Rejected(self._stale())
|
|
return result
|
|
|
|
@staticmethod
|
|
def _normalize_feature_plan_visual_references(plan: FeaturePlan, contract: dict[str, Any]) -> FeaturePlan:
|
|
"""Drop non-owning visual references from feature nodes.
|
|
|
|
A node's ``claim_ids`` drive synchronous deterministic acceptance.
|
|
Visual claims are owned solely by ``final_claim_ids`` and have no
|
|
node-local verifier. Retaining a repeated visual ID therefore adds
|
|
no behavior and turns an otherwise valid plan into a schema retry.
|
|
The contract validation below still requires every visual claim to be
|
|
present exactly once in ``final_claim_ids``.
|
|
"""
|
|
visual_claim_ids = {
|
|
str(claim.get("claim_id") or "")
|
|
for requirement in contract.get("requirements") or ()
|
|
if isinstance(requirement, dict)
|
|
for claim in requirement.get("acceptance_claims") or ()
|
|
if isinstance(claim, dict)
|
|
and claim.get("verification_mode") != "deterministic"
|
|
and isinstance(claim.get("claim_id"), str)
|
|
}
|
|
if not visual_claim_ids or not any(
|
|
claim_id in visual_claim_ids
|
|
for node in plan.nodes
|
|
for claim_id in node.claim_ids
|
|
):
|
|
return plan
|
|
normalized = plan.model_copy(deep=True)
|
|
for node in normalized.nodes:
|
|
node.claim_ids = [claim_id for claim_id in node.claim_ids if claim_id not in visual_claim_ids]
|
|
return normalized
|
|
|
|
@staticmethod
|
|
def _assign_unowned_global_health_claims(plan: FeaturePlan, contract: dict[str, Any]) -> FeaturePlan:
|
|
"""Bind global solid-health claims to the unique root body feature.
|
|
|
|
``single_connected_body`` and ``solid_count_equals`` are checked as
|
|
global health on every feature checkpoint. When a plan has exactly
|
|
one root additive feature, their node owner is consequently
|
|
determined without choosing any geometry strategy. This prevents an
|
|
otherwise complete plan from failing merely because an author omitted
|
|
the redundant ownership annotation.
|
|
"""
|
|
claims = {
|
|
str(claim.get("claim_id") or ""): str(claim.get("claim_kind") or "")
|
|
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("claim_id"), str)
|
|
}
|
|
assigned = {claim_id for node in plan.nodes for claim_id in node.claim_ids}
|
|
unowned = [
|
|
claim_id for claim_id, claim_kind in claims.items()
|
|
if claim_id not in assigned and claim_kind in {"single_connected_body", "solid_count_equals"}
|
|
]
|
|
roots = [
|
|
node for node in plan.nodes
|
|
if not node.depends_on and node.atomic_id in {"extrude_add_blind", "extrude_add_two_sided", "revolve_add", "sphere_add"}
|
|
]
|
|
if not unowned or len(roots) != 1:
|
|
return plan
|
|
normalized = plan.model_copy(deep=True)
|
|
root_id = roots[0].node_id
|
|
for node in normalized.nodes:
|
|
if node.node_id == root_id:
|
|
node.claim_ids = [*node.claim_ids, *unowned]
|
|
break
|
|
return normalized
|
|
|
|
def _required_replacements(self, plan: FeaturePlan, task_id: str) -> set[str]:
|
|
scheduler = FeatureScheduler(plan, self.repository.ledger_events(task_id))
|
|
statuses = scheduler.statuses()
|
|
failed = {node_id for node_id, status in statuses.items() if status == "failed"}
|
|
if not failed:
|
|
return set()
|
|
children: dict[str, set[str]] = {node.node_id: set() for node in plan.nodes}
|
|
for node in plan.nodes:
|
|
for dependency in node.depends_on:
|
|
children.setdefault(dependency, set()).add(node.node_id)
|
|
result = set(failed)
|
|
pending = list(failed)
|
|
while pending:
|
|
current = pending.pop()
|
|
for child in children.get(current, set()):
|
|
if statuses.get(child) != "done" and child not in result:
|
|
result.add(child)
|
|
pending.append(child)
|
|
return result
|
|
|
|
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),
|
|
*self._relationship_claim_errors(normalized_output, targets),
|
|
]
|
|
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.2", "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
|
|
|
|
@staticmethod
|
|
def _relationship_claim_errors(output: CompiledRequirementsSpec, targets: list[str]) -> list[dict[str, str]]:
|
|
"""Require explicit coverage for an unambiguous centered-bore target.
|
|
|
|
The compiler remains free to choose claims for open-ended CAD prose.
|
|
A checklist item that explicitly says a bore is centered/concentric is
|
|
different: dropping that relationship leaves a measurable user fact
|
|
with no acceptance owner. The service only checks its presence here;
|
|
the registry validates its numeric parameters independently.
|
|
"""
|
|
errors: list[dict[str, str]] = []
|
|
for index, (target, requirement) in enumerate(zip(targets, output.requirements, strict=True)):
|
|
lowered = target.casefold()
|
|
if not any(marker in lowered for marker in _CENTERED_BORE_MARKERS):
|
|
continue
|
|
if not any(marker in lowered for marker in _BORE_MARKERS):
|
|
continue
|
|
if any(
|
|
claim.claim_kind == "concentric_bore_to_outer_cylinder"
|
|
for claim in requirement.acceptance_claims
|
|
):
|
|
continue
|
|
errors.append({
|
|
"path": f"/requirements/{index}/acceptance_claims",
|
|
"message": "A centered or concentric bore requires concentric_bore_to_outer_cylinder coverage.",
|
|
})
|
|
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 self._is_slot_misclassified_as_corner_bore_pattern(claim, target):
|
|
warnings.append(
|
|
f"rectangular_corner_through_bore_pattern for checklist item '{target}' describes an obround slot, not four circular bores, so it was compiled as visual review."
|
|
)
|
|
claim.claim_kind = "visual"
|
|
claim.expected = {"description": target[:360]}
|
|
normalized_claims.append(claim)
|
|
continue
|
|
if self._is_unbacked_coaxial_bore_group(normalized, claim):
|
|
warnings.append(
|
|
f"coaxial_through_bore_group for checklist item '{target}' has no matching multi-bore target, so it was compiled as visual review. "
|
|
"Use concentric_bore_to_outer_cylinder for one central bore and one outer cylinder."
|
|
)
|
|
claim.claim_kind = "visual"
|
|
claim.expected = {"description": target[:360]}
|
|
normalized_claims.append(claim)
|
|
continue
|
|
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]},
|
|
})]
|
|
self._derive_centered_bore_claims(normalized, targets)
|
|
return normalized, list(dict.fromkeys(warnings))
|
|
|
|
@staticmethod
|
|
def _is_slot_misclassified_as_corner_bore_pattern(claim: Any, target: str) -> bool:
|
|
"""Keep a circular-hole verifier from accepting or rejecting a slot.
|
|
|
|
``rectangular_corner_through_bore_pattern`` measures four complete
|
|
cylindrical bores at equal edge offsets. An obround slot has two arc
|
|
ends and straight flanks; treating its stated length as an edge offset
|
|
produces an unsatisfiable contract even when the CAD is correct.
|
|
"""
|
|
return (
|
|
getattr(claim, "claim_kind", "") == "rectangular_corner_through_bore_pattern"
|
|
and any(marker in target.casefold() for marker in _OBROUND_SLOT_MARKERS)
|
|
)
|
|
|
|
@staticmethod
|
|
def _derive_centered_bore_claims(output: CompiledRequirementsSpec, targets: list[str]) -> None:
|
|
"""Attach a measurable concentricity claim when its inputs are frozen.
|
|
|
|
The requirements compiler receives a Markdown checklist, not runtime
|
|
geometry IDs. Once it has already compiled an external cylindrical
|
|
diameter and an explicitly centred bore diameter, their relationship
|
|
is a service-owned mechanical consequence. Requiring an author to
|
|
remember the internal verifier name makes a complete user request
|
|
fail for a bookkeeping omission rather than a CAD decision.
|
|
"""
|
|
outer_diameters: list[float] = []
|
|
for requirement in output.requirements:
|
|
for claim in requirement.acceptance_claims:
|
|
expected = claim.expected
|
|
diameter = expected.get("diameter_mm") if isinstance(expected, dict) else None
|
|
if claim.claim_kind == "outer_cylindrical_surface" and isinstance(diameter, (int, float)) and float(diameter) > 0:
|
|
outer_diameters.append(float(diameter))
|
|
if not outer_diameters:
|
|
return
|
|
outer_diameter = max(outer_diameters)
|
|
bore_claim_kinds = frozenset({"through_cylindrical_bore", "cylindrical_bore", "cylindrical_bore_depth"})
|
|
for target, requirement in zip(targets, output.requirements, strict=True):
|
|
lowered = target.casefold()
|
|
if not any(marker in lowered for marker in _CENTERED_BORE_MARKERS):
|
|
continue
|
|
if not any(marker in lowered for marker in _BORE_MARKERS):
|
|
continue
|
|
if any(claim.claim_kind == "concentric_bore_to_outer_cylinder" for claim in requirement.acceptance_claims):
|
|
continue
|
|
bore_diameter = next((
|
|
float(claim.expected["diameter_mm"])
|
|
for claim in requirement.acceptance_claims
|
|
if claim.claim_kind in bore_claim_kinds
|
|
and isinstance(claim.expected, dict)
|
|
and isinstance(claim.expected.get("diameter_mm"), (int, float))
|
|
and float(claim.expected["diameter_mm"]) > 0
|
|
), None)
|
|
if bore_diameter is None:
|
|
continue
|
|
requirement.acceptance_claims.append(AcceptanceClaimInput.model_validate({
|
|
"claim_kind": "concentric_bore_to_outer_cylinder",
|
|
"expected": {
|
|
"bore_diameter_mm": bore_diameter,
|
|
"outer_diameter_mm": outer_diameter,
|
|
"tolerance_mm": 0.01,
|
|
},
|
|
}))
|
|
|
|
@staticmethod
|
|
def _is_unbacked_coaxial_bore_group(output: CompiledRequirementsSpec, claim: Any) -> bool:
|
|
"""Reject a bore-group verifier when the contract has no such group.
|
|
|
|
``coaxial_through_bore_group`` measures multiple inner bores of one
|
|
diameter. It cannot prove a lone central bore is concentric with an
|
|
external cylindrical wall. This is a mechanical consistency check:
|
|
some through-bore claim must request at least the group count.
|
|
"""
|
|
if getattr(claim, "claim_kind", "") != "coaxial_through_bore_group":
|
|
return False
|
|
expected = getattr(claim, "expected", {})
|
|
if not isinstance(expected, dict):
|
|
return True
|
|
diameter = expected.get("diameter_mm")
|
|
count = expected.get("count")
|
|
if not isinstance(diameter, (int, float)) or not isinstance(count, int):
|
|
return True
|
|
for requirement in output.requirements:
|
|
for candidate in requirement.acceptance_claims:
|
|
candidate_expected = getattr(candidate, "expected", {})
|
|
if (
|
|
getattr(candidate, "claim_kind", "") == "through_cylindrical_bore"
|
|
and isinstance(candidate_expected, dict)
|
|
and isinstance(candidate_expected.get("diameter_mm"), (int, float))
|
|
and isinstance(candidate_expected.get("count"), int)
|
|
and abs(float(candidate_expected["diameter_mm"]) - float(diameter)) <= 1e-9
|
|
and int(candidate_expected["count"]) >= count
|
|
):
|
|
return False
|
|
return True
|
|
|
|
@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))
|