优化流程
This commit is contained in:
@@ -20,11 +20,20 @@ from app.cad_agent.adapters.event_publisher import IdempotentInProcessPublisher
|
||||
from app.cad_agent.adapters.review_gateway import RenderedReviewGateway
|
||||
from app.cad_agent.adapters.runtime import ProfileCadRuntime
|
||||
from app.cad_agent.adapters.sqlite_repository import SqliteTaskRepository
|
||||
from app.cad_agent.adapters.verifier import RegistryVerifierExecutor
|
||||
from app.cad_agent.application.capabilities import cached_model_capability, conformance_hash, conformance_tools, verify_model_capability
|
||||
from app.cad_agent.application.action_handlers import ActionCommandHandler
|
||||
from app.cad_agent.application.llm_contracts import (
|
||||
RequirementsAuthorOutput,
|
||||
CandidateReview,
|
||||
CompiledRequirementsSpec,
|
||||
MarkdownDocument,
|
||||
NextAction,
|
||||
StatelessCandidateReview,
|
||||
requirements_spec_schema,
|
||||
StatelessGeometryConclusion,
|
||||
canonical_validate,
|
||||
canonical_validate_schema,
|
||||
compiled_requirements_schema,
|
||||
sanitize_compiled_requirements_arguments,
|
||||
stateless_final_review_schema,
|
||||
stateless_next_action_schema,
|
||||
stateless_rollback_checkpoint_schema,
|
||||
@@ -34,7 +43,7 @@ from app.cad_agent.application.requirements import RequirementsCommandHandler
|
||||
from app.cad_agent.application.results import Accepted, Rejected, Waiting
|
||||
from app.cad_agent.application.workflow import ModelIdentity, WorkflowConfig, WorkflowCoordinator
|
||||
from app.cad_agent.domain.errors import ErrorCode
|
||||
from app.cad_agent.domain.operation_contract import fragment_schema
|
||||
from app.cad_agent.domain.operation_contract import fragment_schema, validate_fragment
|
||||
from app.cad_agent.domain.state import TaskPhase, TaskState, legal_transitions, retry_resume_event, transition
|
||||
from app.cad_agent.domain.verifier_registry import default_registry
|
||||
from app.models.contracts import ChatMessage
|
||||
@@ -63,20 +72,48 @@ def settings(root: Path) -> Settings:
|
||||
)
|
||||
|
||||
|
||||
def ready_spec(*, spacing: float = 60.0) -> RequirementsAuthorOutput:
|
||||
return RequirementsAuthorOutput.model_validate({
|
||||
"outcome": "ready",
|
||||
"summary": "Circular flange with an eight-hole pattern.",
|
||||
"assumptions": ["Dimensions use millimetres."],
|
||||
"requirements": [{
|
||||
"statement": f"Use eight holes with a declared spacing of {spacing:g} degrees.",
|
||||
"assumptions": [],
|
||||
"acceptance_claims": [{
|
||||
"claim_kind": "visual",
|
||||
"expected": {"description": f"Eight holes are shown with the requested {spacing:g} degree declaration."},
|
||||
}],
|
||||
}],
|
||||
})
|
||||
def requirements_document() -> MarkdownDocument:
|
||||
return MarkdownDocument(markdown="""# Design Understanding
|
||||
|
||||
Simple functional flange.
|
||||
|
||||
# Explicit User Requirements
|
||||
|
||||
- Create a simple flange.
|
||||
|
||||
# Engineering Defaults and Assumptions
|
||||
|
||||
- Use a circular body, central through bore, and four equally spaced mounting holes.
|
||||
|
||||
# Dimensions and Coordinate Convention
|
||||
|
||||
- Units are mm. The body is diameter 100 and thickness 10; bore diameter 30; four holes diameter 10 on radius 35.
|
||||
|
||||
# Open Uncertainties
|
||||
|
||||
- None.
|
||||
""")
|
||||
|
||||
|
||||
def completion_target() -> MarkdownDocument:
|
||||
return MarkdownDocument(markdown="""# Completion Target
|
||||
|
||||
- [ ] One connected cylindrical flange body, 100 mm outer diameter and 10 mm thickness.
|
||||
- [ ] Centered 30 mm through bore.
|
||||
- [ ] Four 10 mm mounting holes on a circular pattern of 35 mm pitch radius.
|
||||
""")
|
||||
|
||||
|
||||
def compiled_flange() -> CompiledRequirementsSpec:
|
||||
return CompiledRequirementsSpec.model_validate({"requirements": [
|
||||
{"assumptions": [], "acceptance_claims": [{"claim_kind": "single_connected_body", "expected": {}}, {"claim_kind": "outer_cylindrical_surface", "expected": {"diameter_mm": 100, "tolerance_mm": 0.1}}, {"claim_kind": "bbox_dimension_mm", "expected": {"axis": "z", "value": 10, "tolerance_mm": 0.1}}]},
|
||||
{"assumptions": [], "acceptance_claims": [{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 30, "count": 1, "tolerance_mm": 0.1}}]},
|
||||
{"assumptions": [], "acceptance_claims": [{"claim_kind": "circular_hole_pattern", "expected": {"count": 4, "diameter_mm": 10, "pitch_radius_mm": 35, "tolerance_mm": 0.1}}]},
|
||||
]})
|
||||
|
||||
|
||||
def modeling_plan() -> MarkdownDocument:
|
||||
return MarkdownDocument(markdown="# Modeling Plan\n\n1. Create the circular flange body.\n2. Cut the centered bore.\n3. Add the circular mounting-hole pattern.\n")
|
||||
|
||||
|
||||
def walk_keys(value: object) -> set[str]:
|
||||
@@ -94,36 +131,62 @@ def walk_keys(value: object) -> set[str]:
|
||||
class CadV3ProtocolTests(unittest.TestCase):
|
||||
def test_state_machine_has_no_requirements_review_phase(self) -> None:
|
||||
self.assertNotIn("REVIEWING_REQUIREMENTS", {phase.value for phase in TaskPhase})
|
||||
state = TaskState("cad_123456abcdef", TaskPhase.DRAFTING_REQUIREMENTS, 0)
|
||||
approved = transition(state, "requirements_approved", requirements_contract_path="documents/contract.json")
|
||||
state = TaskState("cad_123456abcdef", TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT, 0)
|
||||
document = transition(state, "requirements_document_written", requirements_document_path="requirements.md")
|
||||
target = transition(document, "completion_target_written", completion_target_path="completion-target.md")
|
||||
compiled = transition(target, "requirements_compiled", requirements_contract_path="requirements-contract.json")
|
||||
approved = transition(compiled, "modeling_plan_written", modeling_plan_path="modeling-plan.md")
|
||||
self.assertEqual(approved.phase, TaskPhase.AWAITING_ACTION)
|
||||
self.assertNotIn("requirements_finalized", {event for _phase, event in legal_transitions()})
|
||||
|
||||
def test_waiting_retry_resumes_exact_source_phase(self) -> None:
|
||||
state = TaskState("cad_123456abcdef", TaskPhase.DRAFTING_REQUIREMENTS, 0)
|
||||
state = TaskState("cad_123456abcdef", TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT, 0)
|
||||
waiting = transition(state, "waiting_retry", error=ErrorCode.MODEL_PROTOCOL_CHECK_PENDING)
|
||||
self.assertEqual(retry_resume_event(waiting), "resume_drafting_requirements")
|
||||
self.assertEqual(retry_resume_event(waiting), "resume_drafting_requirements_document")
|
||||
resumed = transition(waiting, retry_resume_event(waiting) or "")
|
||||
self.assertEqual(resumed.phase, TaskPhase.DRAFTING_REQUIREMENTS)
|
||||
self.assertEqual(resumed.phase, TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT)
|
||||
self.assertIsNone(resumed.retry_from_phase)
|
||||
|
||||
def test_llm_schemas_exclude_server_owned_runtime_ids(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
runtime = ProfileCadRuntime(settings(Path(temporary)))
|
||||
schemas = [
|
||||
requirements_spec_schema(default_registry().expected_one_of_schema()),
|
||||
MarkdownDocument.model_json_schema(),
|
||||
compiled_requirements_schema(default_registry().expected_one_of_schema(exclude_claim_kinds=frozenset({"coaxial", "coplanar"})), 2),
|
||||
stateless_next_action_schema(list(runtime.supported_atomic_ids())),
|
||||
StatelessCandidateReview.model_json_schema(),
|
||||
StatelessGeometryConclusion.model_json_schema(),
|
||||
stateless_final_review_schema(2),
|
||||
]
|
||||
forbidden = {
|
||||
"task_id", "working_head", "requirement_id", "requirement_ids", "claim_id",
|
||||
"candidate_id", "action_id", "evidence_id", "evidence_refs", "source_id", "source_ids",
|
||||
"draft_id", "attachment_id",
|
||||
"draft_id", "attachment_id", "record_ids",
|
||||
}
|
||||
for schema in schemas:
|
||||
self.assertFalse(walk_keys(schema) & forbidden, walk_keys(schema) & forbidden)
|
||||
|
||||
def test_compiled_requirements_ignores_non_executable_extra_fields(self) -> None:
|
||||
schema = compiled_requirements_schema(default_registry().expected_one_of_schema(exclude_claim_kinds=frozenset({"coaxial", "coplanar"})), 1)
|
||||
raw = json.dumps({
|
||||
"assumptions": ["top-level notes from the compiler are not executable"],
|
||||
"requirements": [{
|
||||
"statement": "model-added copy of the checklist text",
|
||||
"assumptions": [],
|
||||
"acceptance_claims": [{
|
||||
"claim_kind": "single_connected_body",
|
||||
"expected": {},
|
||||
"evidence": "not part of the compiler contract",
|
||||
}],
|
||||
}],
|
||||
})
|
||||
sanitized = sanitize_compiled_requirements_arguments(raw)
|
||||
self.assertIsInstance(sanitized, str)
|
||||
self.assertIsNone(canonical_validate_schema(sanitized, schema))
|
||||
parsed = canonical_validate(sanitized, CompiledRequirementsSpec)
|
||||
self.assertIsInstance(parsed, CompiledRequirementsSpec)
|
||||
self.assertEqual(parsed.requirements[0].acceptance_claims[0].claim_kind, "single_connected_body")
|
||||
|
||||
def test_dynamic_tokens_are_enum_constrained(self) -> None:
|
||||
rollback = stateless_rollback_checkpoint_schema(["checkpoint_one"])
|
||||
self.assertEqual(rollback["properties"]["checkpoint_token"], {"enum": ["checkpoint_one"]})
|
||||
@@ -133,28 +196,363 @@ class CadV3ProtocolTests(unittest.TestCase):
|
||||
selector = schema["properties"]["feature"]["properties"]["selector_tokens"]["items"]
|
||||
self.assertEqual(selector, {"enum": ["selector_one"]})
|
||||
|
||||
def test_requirements_spec_freezes_once_and_preserves_user_value(self) -> None:
|
||||
def test_root_extrusion_schema_fixes_world_xy_datum_without_deciding_z(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
runtime = ProfileCadRuntime(settings(Path(temporary)))
|
||||
contract = runtime.operation_contract("extrude_add_blind")
|
||||
fragment = {
|
||||
"sketch": {"workplane": {"origin_mm": [0, -6, 0], "normal": [0, 0, 1], "x_dir": [1, 0, 0]}, "profile": {"type": "circle", "center": [0, 0], "radius_mm": 60}},
|
||||
"feature": {"atomic_id": "extrude_add_blind", "params": {"distance_mm": 12}},
|
||||
}
|
||||
self.assertTrue(validate_fragment(contract, fragment, selector_tokens=[], root_xy_datum=True))
|
||||
fragment["sketch"]["workplane"]["origin_mm"] = [0, 0, -6]
|
||||
self.assertEqual(validate_fragment(contract, fragment, selector_tokens=[], root_xy_datum=True), [])
|
||||
|
||||
def test_runtime_keeps_executable_feature_prefix_when_later_feature_is_invalid(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
runtime = ProfileCadRuntime(settings(Path(temporary)))
|
||||
cdsl = {
|
||||
"schema": "cad.cdsl.llm.v1",
|
||||
"schema_version": "1.1.0",
|
||||
"kind": "part",
|
||||
"part_id": "partial_rebuild",
|
||||
"geometry": {"sketches": [{
|
||||
"id": "sketch_001",
|
||||
"workplane": {"origin_mm": [0, 0, 0], "normal": [0, 0, 1], "x_dir": [1, 0, 0]},
|
||||
"profile": {"type": "circle", "center": [0, 0], "radius_mm": 10},
|
||||
}]},
|
||||
"features": [
|
||||
{"id": "feature_001", "atomic_id": "extrude_add_blind", "params": {"distance_mm": 5}, "depends_on": [], "sketch_id": "sketch_001"},
|
||||
{"id": "feature_002", "atomic_id": "not_an_engine_operation", "params": {}, "depends_on": ["feature_001"]},
|
||||
],
|
||||
}
|
||||
rebuilt, failures = runtime.rebuild_best_effort(cdsl, str(Path(temporary) / "candidate"), "partial_rebuild", "candidate")
|
||||
self.assertEqual(rebuilt["executed_feature_ids"], ["feature_001"])
|
||||
self.assertEqual(len(failures), 1)
|
||||
self.assertEqual(failures[0]["feature_id"], "feature_002")
|
||||
|
||||
def test_action_submission_keeps_partial_feature_batch_for_review(self) -> None:
|
||||
class PassingVerifier:
|
||||
def evaluate(self, claims: list[dict[str, object]], _facts: dict[str, object]) -> list[dict[str, object]]:
|
||||
return [
|
||||
{
|
||||
"claim_id": str(claim.get("claim_id") or ""),
|
||||
"claim_kind": str(claim.get("claim_kind") or ""),
|
||||
"deterministic": True,
|
||||
"status": "pass",
|
||||
"evidence": {},
|
||||
}
|
||||
for claim in claims
|
||||
]
|
||||
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
repository = SqliteTaskRepository(root / "state.sqlite3")
|
||||
artifacts = FileArtifactStore(root / "tasks")
|
||||
runtime = ProfileCadRuntime(settings(root))
|
||||
requirements = RequirementsCommandHandler(repository, artifacts, default_registry())
|
||||
actions = ActionCommandHandler(repository, artifacts, runtime, PassingVerifier())
|
||||
task_id = "cad_123456abcdef"
|
||||
repository.create_task(task_id, "Create a flange.")
|
||||
artifacts.initialize_task(task_id, "Create a flange.")
|
||||
requirements.submit_requirements_document(task_id, requirements_document(), invocation_id="requirements_document")
|
||||
requirements.submit_completion_target(task_id, completion_target(), invocation_id="completion_target")
|
||||
requirements.submit_compiled_spec(task_id, compiled_flange(), invocation_id="requirements_compile")
|
||||
requirements.submit_modeling_plan(task_id, modeling_plan(), invocation_id="modeling_plan")
|
||||
state = repository.get_state(task_id)
|
||||
proposal = NextAction(
|
||||
working_head=state.working_head,
|
||||
intent="Create a two-feature batch.",
|
||||
requirement_ids=["req_001"],
|
||||
atomic_id="extrude_add_blind",
|
||||
expected_change="Keep the executable part of the batch.",
|
||||
)
|
||||
self.assertIsInstance(actions.propose_next_action(task_id, proposal, invocation_id="action"), Accepted)
|
||||
fragment = {
|
||||
"sketch": {
|
||||
"workplane": {"origin_mm": [0, 0, 0], "normal": [0, 0, 1], "x_dir": [1, 0, 0]},
|
||||
"profile": {"type": "circle", "center": [0, 0], "radius_mm": 10},
|
||||
},
|
||||
"feature": {"atomic_id": "extrude_add_blind", "params": {"distance_mm": 5}},
|
||||
}
|
||||
cdsl = {
|
||||
"schema": "cad.cdsl.llm.v1",
|
||||
"schema_version": "1.1.0",
|
||||
"kind": "part",
|
||||
"part_id": "partial_batch",
|
||||
"geometry": {"sketches": []},
|
||||
"features": [
|
||||
{"id": "feature_001", "atomic_id": "extrude_add_blind", "params": {"distance_mm": 5}, "depends_on": []},
|
||||
{"id": "feature_002", "atomic_id": "extrude_add_blind", "params": {"distance_mm": 5}, "depends_on": ["feature_001"]},
|
||||
],
|
||||
}
|
||||
audit = {
|
||||
"schema_version": "cad.v3.fragment-audit.v1",
|
||||
"atomic_id": "extrude_add_blind",
|
||||
"fragment_hash": "hash",
|
||||
"contract_hash": runtime.operation_contract("extrude_add_blind")["contract_hash"],
|
||||
"assigned_feature_ids": ["feature_001", "feature_002"],
|
||||
"assigned_sketch_ids": [],
|
||||
"selector_snapshot_id": "",
|
||||
"selector_tokens": [],
|
||||
"reference_snapshot_id": "",
|
||||
"reference_tokens": [],
|
||||
}
|
||||
rebuilt = {
|
||||
"executed_feature_ids": ["feature_001"],
|
||||
"health": {"solid_count": 1},
|
||||
"topology": {"records": []},
|
||||
"report": {},
|
||||
"render_manifest": {},
|
||||
"paths": {"cdsl": "model.cdsl.json", "step": "model.step", "glb": "model.glb", "topology": "model.topology.json", "report": "rebuild-report.json"},
|
||||
}
|
||||
operation_failures = [{"feature_index": 1, "feature_id": "feature_002", "message": "failed after feature_001"}]
|
||||
with patch.object(runtime, "materialize_fragment", return_value=(cdsl, audit)), patch.object(runtime, "rebuild_best_effort", return_value=(rebuilt, operation_failures)):
|
||||
result = actions.submit_cdsl_fragment(task_id, fragment, invocation_id="fragment")
|
||||
self.assertIsInstance(result, Accepted)
|
||||
reviewing = repository.get_state(task_id)
|
||||
self.assertEqual(reviewing.phase, TaskPhase.CANDIDATE_REVIEW)
|
||||
candidate = artifacts.read_stage_json(task_id, reviewing.candidate_stage_id, "candidate.json") or {}
|
||||
self.assertEqual(candidate["executed_feature_ids"], ["feature_001"])
|
||||
self.assertEqual(candidate["operation_failures"], operation_failures)
|
||||
|
||||
def test_best_effort_transition_completes_from_an_executable_checkpoint(self) -> None:
|
||||
state = TaskState("cad_123456abcdef", TaskPhase.AWAITING_ACTION, 7, active_revision="rev_001", repair_required=True)
|
||||
completed = transition(state, "best_effort_completed", error=ErrorCode.BEST_EFFORT_COMPLETED, repair_required=False)
|
||||
self.assertEqual(completed.phase, TaskPhase.COMPLETED)
|
||||
self.assertFalse(completed.repair_required)
|
||||
|
||||
def test_review_rejection_publishes_the_executable_checkpoint_for_repair(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
repository = SqliteTaskRepository(root / "state.sqlite3")
|
||||
artifacts = FileArtifactStore(root / "tasks")
|
||||
runtime = ProfileCadRuntime(settings(root))
|
||||
requirements = RequirementsCommandHandler(repository, artifacts, default_registry())
|
||||
actions = ActionCommandHandler(repository, artifacts, runtime, RegistryVerifierExecutor(default_registry()))
|
||||
task_id = "cad_123456abcdef"
|
||||
repository.create_task(task_id, "Create a simple flange.")
|
||||
artifacts.initialize_task(task_id, "Create a simple flange.")
|
||||
requirements.submit_requirements_document(task_id, requirements_document(), invocation_id="requirements_document")
|
||||
requirements.submit_completion_target(task_id, completion_target(), invocation_id="completion_target")
|
||||
requirements.submit_compiled_spec(task_id, compiled_flange(), invocation_id="requirements_compile")
|
||||
requirements.submit_modeling_plan(task_id, modeling_plan(), invocation_id="modeling_plan")
|
||||
state = repository.get_state(task_id)
|
||||
proposal = NextAction(
|
||||
working_head=state.working_head,
|
||||
intent="Create the base body.",
|
||||
requirement_ids=["req_001"],
|
||||
atomic_id="extrude_add_blind",
|
||||
expected_change="Create the circular body.",
|
||||
)
|
||||
self.assertIsInstance(actions.propose_next_action(task_id, proposal, invocation_id="action"), Accepted)
|
||||
fragment = {
|
||||
"sketch": {
|
||||
"workplane": {"origin_mm": [0, 0, 0], "normal": [0, 0, 1], "x_dir": [1, 0, 0]},
|
||||
"profile": {"type": "circle", "center": [0, 0], "radius_mm": 50},
|
||||
},
|
||||
"feature": {"atomic_id": "extrude_add_blind", "params": {"distance_mm": 10}},
|
||||
}
|
||||
self.assertIsInstance(actions.submit_cdsl_fragment(task_id, fragment, invocation_id="fragment"), Accepted)
|
||||
reviewing = repository.get_state(task_id)
|
||||
candidate = artifacts.read_stage_json(task_id, reviewing.candidate_stage_id, "candidate.json") or {}
|
||||
review = CandidateReview(
|
||||
candidate_id=reviewing.candidate_id,
|
||||
working_head=reviewing.pending_action.working_head,
|
||||
verdict="reject",
|
||||
claim_coverage=[
|
||||
{"claim_id": str(item["claim_id"]), "status": str(item["status"]), "evidence_refs": []}
|
||||
for item in candidate["claim_results"]
|
||||
],
|
||||
evidence=["Base body is executable."],
|
||||
issues=["The bore and bolt holes remain to be added."],
|
||||
)
|
||||
result = actions.record_candidate_review(task_id, review, invocation_id="review")
|
||||
self.assertIsInstance(result, Accepted)
|
||||
self.assertEqual(result.payload["status"], "accepted_with_issues")
|
||||
published = repository.get_state(task_id)
|
||||
self.assertEqual(published.phase, TaskPhase.AWAITING_ACTION)
|
||||
self.assertEqual(published.active_revision, "rev_001")
|
||||
self.assertTrue(published.repair_required)
|
||||
completed = actions.finalize_best_effort(task_id, reason=ErrorCode.NO_PROGRESS_LIMIT, invocation_id="best_effort")
|
||||
self.assertIsInstance(completed, Accepted)
|
||||
self.assertEqual(repository.get_state(task_id).phase, TaskPhase.COMPLETED)
|
||||
|
||||
def test_root_checkpoint_is_not_offered_as_a_rollback_target(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
configured = settings(root)
|
||||
repository = SqliteTaskRepository(root / "state.sqlite3")
|
||||
artifacts = FileArtifactStore(root / "tasks")
|
||||
runtime = ProfileCadRuntime(configured)
|
||||
initial = repository.create_task("cad_123456abcdef", "Create a flange.")
|
||||
document = transition(initial, "requirements_document_written", requirements_document_path="requirements.md")
|
||||
target = transition(document, "completion_target_written", completion_target_path="completion-target.md")
|
||||
compiled = transition(target, "requirements_compiled", requirements_contract_path="requirements-contract.json")
|
||||
awaiting = transition(compiled, "modeling_plan_written", modeling_plan_path="modeling-plan.md", repair_required=True)
|
||||
self.assertTrue(repository.compare_and_swap(document))
|
||||
self.assertTrue(repository.compare_and_swap(target))
|
||||
self.assertTrue(repository.compare_and_swap(compiled))
|
||||
self.assertTrue(repository.compare_and_swap(awaiting, events=[{
|
||||
"event": "geometry_conclusion",
|
||||
"decision": "rollback",
|
||||
"working_head": awaiting.working_head,
|
||||
}]))
|
||||
actions = ActionCommandHandler(repository, artifacts, runtime, default_registry())
|
||||
self.assertFalse(actions.rollback_available(awaiting.task_id))
|
||||
|
||||
def test_geometry_conclusion_is_stateless_for_the_author(self) -> None:
|
||||
schema = StatelessGeometryConclusion.model_json_schema()
|
||||
self.assertFalse({"working_head", "evidence_refs"} & walk_keys(schema))
|
||||
|
||||
def test_outer_cylinder_span_merges_oppositely_oriented_two_sided_faces(self) -> None:
|
||||
def outer_face(record_id: str, direction: list[float], bbox: list[float]) -> dict[str, object]:
|
||||
return {
|
||||
"record_id": record_id,
|
||||
"geometry": {
|
||||
"surface_type": "cylinder",
|
||||
"cylinder_role": "outer",
|
||||
"radius_mm": 60.0,
|
||||
"axis_origin_mm": [0.0, 0.0, 0.0],
|
||||
"axis_direction": direction,
|
||||
"bbox_mm": bbox,
|
||||
},
|
||||
}
|
||||
|
||||
facts = {"topology": {"records": [
|
||||
outer_face("upper", [0.0, 0.0, -1.0], [-60.0, -60.0, 0.0, 60.0, 60.0, 6.0]),
|
||||
outer_face("lower", [0.0, 0.0, 1.0], [-60.0, -60.0, -6.0, 60.0, 60.0, 0.0]),
|
||||
]}}
|
||||
result = default_registry().evaluate(
|
||||
"outer_cylindrical_surface",
|
||||
{"diameter_mm": 120.0, "count": 1, "axial_span_mm": 12.0},
|
||||
facts,
|
||||
)
|
||||
self.assertEqual(result["status"], "pass")
|
||||
self.assertEqual(result["evidence"]["axial_spans_mm"], [12.0])
|
||||
self.assertEqual(result["evidence"]["tolerance_mm"], 0.1)
|
||||
|
||||
def test_compiler_persists_default_tolerance_for_axial_outer_cylinder(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
repository = SqliteTaskRepository(root / "state.sqlite3")
|
||||
artifacts = FileArtifactStore(root / "tasks")
|
||||
handler = RequirementsCommandHandler(repository, artifacts, default_registry())
|
||||
task_id = "cad_123456abcdef"
|
||||
repository.create_task(task_id, "Use 8 holes around a full circle at 60 degrees.")
|
||||
artifacts.initialize_task(task_id, "Use 8 holes around a full circle at 60 degrees.")
|
||||
result = handler.submit_spec(task_id, ready_spec(spacing=60), invocation_id="requirements_once")
|
||||
repository.create_task(task_id, "Create a flange.")
|
||||
artifacts.initialize_task(task_id, "Create a flange.")
|
||||
handler.submit_requirements_document(task_id, requirements_document(), invocation_id="requirements_document")
|
||||
handler.submit_completion_target(task_id, completion_target(), invocation_id="completion_target")
|
||||
compiled = CompiledRequirementsSpec.model_validate({"requirements": [
|
||||
{"assumptions": [], "acceptance_claims": [{"claim_kind": "outer_cylindrical_surface", "expected": {"diameter_mm": 100, "axial_span_mm": 10, "count": 1}}]},
|
||||
{"assumptions": [], "acceptance_claims": [{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 30, "count": 1, "tolerance_mm": 0.1}}]},
|
||||
{"assumptions": [], "acceptance_claims": [{"claim_kind": "circular_hole_pattern", "expected": {"diameter_mm": 10, "count": 4, "pitch_radius_mm": 35, "tolerance_mm": 0.1}}]},
|
||||
]})
|
||||
self.assertIsInstance(handler.submit_compiled_spec(task_id, compiled, invocation_id="requirements_compile"), Accepted)
|
||||
state = repository.get_state(task_id)
|
||||
contract = artifacts.read_requirements_contract(task_id, state.requirements_contract_path) or {}
|
||||
first_claim = contract["requirements"][0]["acceptance_claims"][0]
|
||||
self.assertEqual(first_claim["expected"]["tolerance_mm"], 0.1)
|
||||
|
||||
def test_record_bound_compiler_claims_are_visualized_before_contract_freeze(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
repository = SqliteTaskRepository(root / "state.sqlite3")
|
||||
artifacts = FileArtifactStore(root / "tasks")
|
||||
handler = RequirementsCommandHandler(repository, artifacts, default_registry())
|
||||
task_id = "cad_123456abcdef"
|
||||
repository.create_task(task_id, "Create a simple flange.")
|
||||
artifacts.initialize_task(task_id, "Create a simple flange.")
|
||||
handler.submit_requirements_document(task_id, requirements_document(), invocation_id="requirements_document")
|
||||
handler.submit_completion_target(task_id, completion_target(), invocation_id="completion_target")
|
||||
compiled = CompiledRequirementsSpec.model_validate({"requirements": [
|
||||
{"assumptions": [], "acceptance_claims": [{"claim_kind": "coaxial", "expected": {"record_ids": ["outer", "bore"], "tolerance": 0.01}}]},
|
||||
{"assumptions": [], "acceptance_claims": [{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 30, "count": 1, "tolerance_mm": 0.1}}]},
|
||||
{"assumptions": [], "acceptance_claims": [{"claim_kind": "coplanar", "expected": {"record_ids": ["top_face", "bottom_face"], "tolerance_mm": 0.1}}]},
|
||||
]})
|
||||
self.assertIsInstance(handler.submit_compiled_spec(task_id, compiled, invocation_id="requirements_compile"), Accepted)
|
||||
state = repository.get_state(task_id)
|
||||
contract = artifacts.read_requirements_contract(task_id, state.requirements_contract_path) or {}
|
||||
claims = [claim for requirement in contract["requirements"] for claim in requirement["acceptance_claims"]]
|
||||
self.assertEqual([claim["claim_kind"] for claim in claims], ["visual", "through_cylindrical_bore", "visual"])
|
||||
self.assertEqual([claim["verification_mode"] for claim in claims], ["visual", "deterministic", "visual"])
|
||||
self.assertTrue(any("coaxial verifier" in warning for warning in contract["verification_warnings"]))
|
||||
self.assertTrue(any("coplanar verifier" in warning for warning in contract["verification_warnings"]))
|
||||
|
||||
def test_local_cylindrical_span_does_not_become_global_bbox_requirement(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
repository = SqliteTaskRepository(root / "state.sqlite3")
|
||||
artifacts = FileArtifactStore(root / "tasks")
|
||||
handler = RequirementsCommandHandler(repository, artifacts, default_registry())
|
||||
task_id = "cad_123456abcdef"
|
||||
repository.create_task(task_id, "Create a stepped hub adapter.")
|
||||
artifacts.initialize_task(task_id, "Create a stepped hub adapter.")
|
||||
handler.submit_requirements_document(task_id, requirements_document(), invocation_id="requirements_document")
|
||||
handler.submit_completion_target(task_id, MarkdownDocument(markdown="""# Completion Target
|
||||
|
||||
- [ ] A centered solid cylindrical flange body is present with 120 mm outer diameter and 12 mm thickness.
|
||||
"""), invocation_id="completion_target")
|
||||
compiled = CompiledRequirementsSpec.model_validate({"requirements": [{
|
||||
"assumptions": [],
|
||||
"acceptance_claims": [
|
||||
{"claim_kind": "outer_cylindrical_surface", "expected": {"diameter_mm": 120, "count": 1, "tolerance_mm": 0.1}},
|
||||
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "z", "value": 12, "tolerance_mm": 0.1}},
|
||||
],
|
||||
}]})
|
||||
self.assertIsInstance(handler.submit_compiled_spec(task_id, compiled, invocation_id="requirements_compile"), Accepted)
|
||||
state = repository.get_state(task_id)
|
||||
contract = artifacts.read_requirements_contract(task_id, state.requirements_contract_path) or {}
|
||||
claims = contract["requirements"][0]["acceptance_claims"]
|
||||
self.assertEqual([claim["claim_kind"] for claim in claims], ["outer_cylindrical_surface"])
|
||||
self.assertEqual(claims[0]["expected"]["axial_span_mm"], 12)
|
||||
self.assertTrue(any("Global bbox Z verifier" in warning for warning in contract["verification_warnings"]))
|
||||
|
||||
def test_requirements_markdown_is_not_rejected_for_missing_headings(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
repository = SqliteTaskRepository(root / "state.sqlite3")
|
||||
artifacts = FileArtifactStore(root / "tasks")
|
||||
handler = RequirementsCommandHandler(repository, artifacts, default_registry())
|
||||
task_id = "cad_123456abcdef"
|
||||
repository.create_task(task_id, "Create a flange.")
|
||||
artifacts.initialize_task(task_id, "Create a flange.")
|
||||
result = handler.submit_requirements_document(task_id, MarkdownDocument(markdown="A simple circular flange with a bore."), invocation_id="plain_markdown")
|
||||
self.assertIsInstance(result, Accepted)
|
||||
self.assertEqual(repository.get_state(task_id).phase, TaskPhase.DRAFTING_COMPLETION_TARGET)
|
||||
|
||||
def test_server_bound_action_accepts_more_than_five_checklist_targets(self) -> None:
|
||||
action = NextAction(
|
||||
working_head="cad_123456abcdef:root:v4",
|
||||
intent="Create the flange body.",
|
||||
requirement_ids=[f"req_{position:03d}" for position in range(1, 8)],
|
||||
atomic_id="extrude_add_blind",
|
||||
expected_change="Add the first solid body.",
|
||||
)
|
||||
self.assertEqual(len(action.requirement_ids), 7)
|
||||
|
||||
def test_markdown_documents_freeze_before_compiled_flange_contract(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
repository = SqliteTaskRepository(root / "state.sqlite3")
|
||||
artifacts = FileArtifactStore(root / "tasks")
|
||||
handler = RequirementsCommandHandler(repository, artifacts, default_registry())
|
||||
task_id = "cad_123456abcdef"
|
||||
repository.create_task(task_id, "Create a simple flange.")
|
||||
artifacts.initialize_task(task_id, "Create a simple flange.")
|
||||
self.assertIsInstance(handler.submit_requirements_document(task_id, requirements_document(), invocation_id="requirements_document"), Accepted)
|
||||
self.assertIsInstance(handler.submit_completion_target(task_id, completion_target(), invocation_id="completion_target"), Accepted)
|
||||
self.assertIsInstance(handler.submit_compiled_spec(task_id, compiled_flange(), invocation_id="requirements_compile"), Accepted)
|
||||
self.assertIsInstance(handler.submit_modeling_plan(task_id, modeling_plan(), invocation_id="modeling_plan"), Accepted)
|
||||
state = repository.get_state(task_id)
|
||||
self.assertEqual(state.phase, TaskPhase.AWAITING_ACTION)
|
||||
contract = artifacts.read_requirements_contract(task_id, state.requirements_contract_path) or {}
|
||||
requirement = contract["requirements"][0]
|
||||
self.assertIn("60 degrees", requirement["statement"])
|
||||
self.assertIn("60 degree", requirement["acceptance_claims"][0]["expected"]["description"])
|
||||
self.assertNotIn("45 degrees", requirement["statement"])
|
||||
self.assertNotIn("applied_normalizations", contract)
|
||||
self.assertEqual(len(contract["requirements"]), 3)
|
||||
claim_kinds = {claim["claim_kind"] for item in contract["requirements"] for claim in item["acceptance_claims"]}
|
||||
self.assertTrue({"single_connected_body", "through_cylindrical_bore", "circular_hole_pattern"}.issubset(claim_kinds))
|
||||
self.assertTrue((artifacts.task_dir(task_id) / "requirements.md").is_file())
|
||||
self.assertTrue((artifacts.task_dir(task_id) / "completion-target.md").is_file())
|
||||
self.assertFalse((artifacts.task_dir(task_id) / "completion.md").exists())
|
||||
self.assertTrue((artifacts.task_dir(task_id) / "modeling-plan.md").is_file())
|
||||
|
||||
def test_invalid_verifier_contract_is_rejected_without_state_change(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
@@ -163,44 +561,18 @@ class CadV3ProtocolTests(unittest.TestCase):
|
||||
artifacts = FileArtifactStore(root / "tasks")
|
||||
handler = RequirementsCommandHandler(repository, artifacts, default_registry())
|
||||
task_id = "cad_123456abcdef"
|
||||
initial = repository.create_task(task_id, "Create one solid.")
|
||||
repository.create_task(task_id, "Create one solid.")
|
||||
artifacts.initialize_task(task_id, "Create one solid.")
|
||||
value = RequirementsAuthorOutput.model_validate({
|
||||
"outcome": "ready", "summary": "One solid", "assumptions": [],
|
||||
"requirements": [{"statement": "One solid", "assumptions": [], "acceptance_claims": [
|
||||
{"claim_kind": "solid_count_equals", "expected": {"value": 0}},
|
||||
]}],
|
||||
})
|
||||
result = handler.submit_spec(task_id, value, invocation_id="invalid_spec")
|
||||
handler.submit_requirements_document(task_id, requirements_document(), invocation_id="requirements_document")
|
||||
handler.submit_completion_target(task_id, completion_target(), invocation_id="completion_target")
|
||||
invalid = CompiledRequirementsSpec.model_validate({"requirements": [{"assumptions": [], "acceptance_claims": [{"claim_kind": "solid_count_equals", "expected": {"value": 0}}]}] * 3})
|
||||
before = repository.get_state(task_id)
|
||||
result = handler.submit_compiled_spec(task_id, invalid, invocation_id="invalid_spec")
|
||||
self.assertIsInstance(result, Rejected)
|
||||
self.assertEqual(result.error.code, ErrorCode.REQUIREMENTS_SPEC_INVALID)
|
||||
self.assertEqual(repository.get_state(task_id), initial)
|
||||
self.assertEqual(repository.get_state(task_id), before)
|
||||
|
||||
def test_clarification_uses_same_task_and_one_question(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
repository = SqliteTaskRepository(root / "state.sqlite3")
|
||||
artifacts = FileArtifactStore(root / "tasks")
|
||||
runtime = ProfileCadRuntime(settings(root))
|
||||
requirements = RequirementsCommandHandler(repository, artifacts, default_registry())
|
||||
workflow = WorkflowCoordinator(WorkflowConfig(4, 2), repository, artifacts, runtime, None, None, requirements, None)
|
||||
task_id = "cad_123456abcdef"
|
||||
repository.create_task(task_id, "Use four holes and use six holes.")
|
||||
artifacts.initialize_task(task_id, "Use four holes and use six holes.")
|
||||
value = RequirementsAuthorOutput.model_validate({
|
||||
"outcome": "clarification", "source_quotes": ["four holes", "six holes"],
|
||||
"question": "Should the part use four holes or six holes?",
|
||||
})
|
||||
result = requirements.submit_spec(task_id, value, invocation_id="clarify_once")
|
||||
self.assertIsInstance(result, Waiting)
|
||||
waiting = repository.get_state(task_id)
|
||||
terminal = workflow.waiting_for_user_terminal(task_id, waiting)
|
||||
self.assertEqual(terminal["questions"], ["Should the part use four holes or six holes?"])
|
||||
self.assertTrue(terminal["userActionRequired"])
|
||||
self.assertTrue(workflow.resume_with_user_clarification(task_id, "Use six holes.", message_id="user_reply"))
|
||||
self.assertEqual(repository.get_state(task_id).phase, TaskPhase.DRAFTING_REQUIREMENTS)
|
||||
|
||||
def test_contract_views_and_completion_result_share_one_spec(self) -> None:
|
||||
def test_completion_result_reports_frozen_checklist(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
repository = SqliteTaskRepository(root / "state.sqlite3")
|
||||
@@ -209,20 +581,23 @@ class CadV3ProtocolTests(unittest.TestCase):
|
||||
task_id = "cad_123456abcdef"
|
||||
repository.create_task(task_id, "Create a coherent flange.")
|
||||
artifacts.initialize_task(task_id, "Create a coherent flange.")
|
||||
handler.submit_spec(task_id, ready_spec(), invocation_id="requirements_once")
|
||||
handler.submit_requirements_document(task_id, requirements_document(), invocation_id="requirements_document")
|
||||
handler.submit_completion_target(task_id, completion_target(), invocation_id="completion_target")
|
||||
handler.submit_compiled_spec(task_id, compiled_flange(), invocation_id="requirements_compile")
|
||||
handler.submit_modeling_plan(task_id, modeling_plan(), invocation_id="modeling_plan")
|
||||
state = repository.get_state(task_id)
|
||||
path = handler.write_completion_result(
|
||||
task_id, state,
|
||||
claim_results=[{"claim_id": "claim_001", "deterministic": False, "status": "pending"}],
|
||||
review={"visual_claims": [{"status": "pass", "evidence": "Reference and render match."}]},
|
||||
claim_results=[{"claim_id": f"claim_{position:03d}", "status": "pass", "evidence": {"measured": True}} for position in range(1, 6)],
|
||||
review={"visual_claims": []},
|
||||
)
|
||||
self.assertEqual(path, "completion-result.md")
|
||||
result = (artifacts.task_dir(task_id) / path).read_text(encoding="utf-8")
|
||||
target = (artifacts.task_dir(task_id) / "completion-target.md").read_text(encoding="utf-8")
|
||||
requirements = (artifacts.task_dir(task_id) / "requirements.md").read_text(encoding="utf-8")
|
||||
self.assertIn("eight-hole pattern", requirements)
|
||||
self.assertIn("visual", target)
|
||||
self.assertIn("visual: pass", result)
|
||||
self.assertIn("Engineering Defaults", requirements)
|
||||
self.assertIn("Centered 30 mm through bore", target)
|
||||
self.assertIn("Centered 30 mm through bore.: pass", result)
|
||||
|
||||
def test_sqlite_schema_contains_only_current_requirement_paths(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
@@ -231,6 +606,9 @@ class CadV3ProtocolTests(unittest.TestCase):
|
||||
with sqlite3.connect(database) as connection:
|
||||
columns = {row[1] for row in connection.execute("PRAGMA table_info(tasks)")}
|
||||
self.assertIn("requirements_spec_path", columns)
|
||||
self.assertIn("requirements_document_path", columns)
|
||||
self.assertIn("completion_target_path", columns)
|
||||
self.assertIn("modeling_plan_path", columns)
|
||||
self.assertIn("clarification_path", columns)
|
||||
self.assertNotIn("requirements_draft_path", columns)
|
||||
self.assertNotIn("requirements_review_path", columns)
|
||||
@@ -242,7 +620,7 @@ class CadV3ProtocolTests(unittest.TestCase):
|
||||
reviewer = conformance_tools(runtime, role="reviewer")
|
||||
author_names = {item["function"]["name"] for item in author}
|
||||
reviewer_names = {item["function"]["name"] for item in reviewer}
|
||||
self.assertIn("submit_requirements_spec", author_names)
|
||||
self.assertTrue({"write_requirements_document", "write_completion_target", "compile_requirements_spec", "write_modeling_plan"}.issubset(author_names))
|
||||
self.assertNotIn("review_requirements", author_names | reviewer_names)
|
||||
self.assertNotIn("get_cdsl_operation_contract", author_names)
|
||||
self.assertEqual(reviewer_names, {"observe_images", "review_candidate", "review_final"})
|
||||
@@ -336,7 +714,7 @@ class CadV3ProtocolTests(unittest.TestCase):
|
||||
))
|
||||
self.assertIsNone(result)
|
||||
self.assertEqual(verify.await_count, 2)
|
||||
self.assertEqual(service.v3.repository.get_state(task_id).phase, TaskPhase.DRAFTING_REQUIREMENTS)
|
||||
self.assertEqual(service.v3.repository.get_state(task_id).phase, TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT)
|
||||
events = [queue.get_nowait(), queue.get_nowait()]
|
||||
self.assertEqual([item[1]["status"] for item in events], ["waiting", "success"])
|
||||
|
||||
|
||||
Reference in New Issue
Block a user