更新功能
This commit is contained in:
@@ -9,7 +9,7 @@ from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from app.models.contracts import ChatMessage, MessagePart
|
||||
from app.services.agent_service import AgentService, CDSL_TOOL_SCHEMA, RepeatedToolArgumentsError, StrictToolSchemaError, TOOL_SCHEMAS, ToolArgumentsError, engine_capability_manifest, normalize_image_analysis, parse_tool_arguments, response_language_instruction, system_prompt, tools_for_model, user_visible_error_message
|
||||
from app.services.agent_service import AgentService, CDSL_TOOL_SCHEMA, RepeatedToolArgumentsError, StrictToolSchemaError, TOOL_SCHEMAS, ToolArgumentsError, engine_capability_manifest, get_repair_step_key, normalize_image_analysis, parse_tool_arguments, response_language_instruction, system_prompt, tools_for_model, user_visible_error_message
|
||||
from app.services.engine_service import load_engine
|
||||
from app.services.quality import QUALITY_RULE_TYPES
|
||||
from app.services.library import CdslLibrary
|
||||
@@ -79,6 +79,38 @@ class ParseToolArgumentsTests(unittest.TestCase):
|
||||
with self.assertRaisesRegex(ToolArgumentsError, "JSON object"):
|
||||
parse_tool_arguments('["not", "tool arguments"]')
|
||||
|
||||
|
||||
class RepairStepKeyTests(unittest.TestCase):
|
||||
def test_generation_and_patch_share_the_same_feature_step_budget(self) -> None:
|
||||
state = {
|
||||
"phase": "CDSL_REPAIR",
|
||||
"feature_plan": {
|
||||
"plan_id": "plan_1",
|
||||
"nodes": [
|
||||
{"id": "boss", "status": "ready"},
|
||||
{"id": "hole", "status": "waiting_for_selection"},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
self.assertEqual(
|
||||
get_repair_step_key(state, "generate_cdsl_model"),
|
||||
get_repair_step_key(state, "patch_cdsl_model"),
|
||||
)
|
||||
|
||||
def test_completed_nodes_do_not_change_the_active_step_key(self) -> None:
|
||||
state = {
|
||||
"phase": "CDSL_REPAIR",
|
||||
"feature_plan": {
|
||||
"plan_id": "plan_1",
|
||||
"nodes": [
|
||||
{"id": "base", "status": "completed"},
|
||||
{"id": "boss", "status": "ready"},
|
||||
],
|
||||
},
|
||||
}
|
||||
self.assertEqual(get_repair_step_key(state, "generate_cdsl_model"), "plan:plan_1:boss")
|
||||
|
||||
def test_recovers_only_the_known_premature_cdsl_wrapper_close(self) -> None:
|
||||
payload = parse_tool_arguments(
|
||||
'{"cdsl":{"schema":"cad.cdsl.llm.v1"}}, "summary":"fixed envelope"}',
|
||||
@@ -88,6 +120,28 @@ class ParseToolArgumentsTests(unittest.TestCase):
|
||||
self.assertEqual(payload["summary"], "fixed envelope")
|
||||
self.assertEqual(payload["cdsl"], {"schema": "cad.cdsl.llm.v1"})
|
||||
|
||||
def test_recovers_trailing_cdsl_metadata_after_a_complete_envelope(self) -> None:
|
||||
payload = parse_tool_arguments(
|
||||
'{"cdsl":{"schema":"cad.cdsl.llm.v1"},"summary":"fixed envelope",'
|
||||
'"assumptions":["metric"]}, "summary":"repeated envelope",'
|
||||
'"assumptions":["metric"]}',
|
||||
recover_cdsl_wrapper=True,
|
||||
)
|
||||
|
||||
self.assertEqual(payload, {
|
||||
"cdsl": {"schema": "cad.cdsl.llm.v1"},
|
||||
"summary": "fixed envelope",
|
||||
"assumptions": ["metric"],
|
||||
})
|
||||
|
||||
def test_rejects_a_second_cdsl_payload_after_a_complete_envelope(self) -> None:
|
||||
with self.assertRaisesRegex(ToolArgumentsError, "trailing content"):
|
||||
parse_tool_arguments(
|
||||
'{"cdsl":{"schema":"cad.cdsl.llm.v1"},"summary":"original",'
|
||||
'"assumptions":[]}, "cdsl":{"schema":"different"}}',
|
||||
recover_cdsl_wrapper=True,
|
||||
)
|
||||
|
||||
def test_does_not_recover_arbitrary_trailing_tool_content(self) -> None:
|
||||
with self.assertRaisesRegex(ToolArgumentsError, "trailing content"):
|
||||
parse_tool_arguments(
|
||||
@@ -123,6 +177,13 @@ class ParseToolArgumentsTests(unittest.TestCase):
|
||||
rule_type = verification["properties"]["rules"]["items"]["properties"]["type"]
|
||||
self.assertEqual(set(rule_type["enum"]), set(QUALITY_RULE_TYPES))
|
||||
|
||||
def test_verification_schema_requires_feature_for_feature_scoped_rules(self) -> None:
|
||||
generate_tool = next(tool for tool in TOOL_SCHEMAS if tool["function"]["name"] == "generate_cdsl_model")
|
||||
rule = generate_tool["function"]["parameters"]["properties"]["verification"]["properties"]["rules"]["items"]
|
||||
self.assertTrue(any("feature" in branch.get("then", {}).get("required", []) for branch in rule["allOf"]))
|
||||
self.assertIn("overall_width", rule["properties"]["type"]["enum"])
|
||||
self.assertIn("overall_height", rule["properties"]["type"]["enum"])
|
||||
|
||||
def test_strict_tool_schema_covers_generation_arguments(self) -> None:
|
||||
tools = tools_for_model(ProviderModel("strict-model", strict_tool_schema=True))
|
||||
strict_tools = [tool["function"]["name"] for tool in tools if tool["function"].get("strict")]
|
||||
|
||||
@@ -13,7 +13,7 @@ from unittest.mock import patch
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "backend"))
|
||||
|
||||
from app.services.agent_service import AgentService # noqa: E402
|
||||
from app.services.agent_service import AgentService, _validate_snapshot_selectors # noqa: E402
|
||||
from app.services.cdsl_patch import CdslPatchError, apply_cdsl_patch # noqa: E402
|
||||
from app.services.engine_service import QualityVerificationError, build_revision # noqa: E402
|
||||
from app.services.library import CdslLibrary # noqa: E402
|
||||
@@ -129,6 +129,69 @@ class VerificationTests(unittest.TestCase):
|
||||
self.assertEqual(report["status"], "passed")
|
||||
self.assertEqual(report["results"][0]["source"], "runtime.topology_records[base_add].bbox_mm")
|
||||
|
||||
def test_feature_bbox_excludes_inherited_runtime_records(self) -> None:
|
||||
document = fixture()
|
||||
document["features"].append({
|
||||
"id": "boss",
|
||||
"atomic_id": "extrude_add_blind",
|
||||
"depends_on": ["base_add"],
|
||||
"sketch_id": "base",
|
||||
"params": {"distance_mm": 8},
|
||||
})
|
||||
rules = validate_verification({"rules": [
|
||||
{"id": "boss_range", "type": "bbox", "feature": "boss", "expected": [8, 58, 58]},
|
||||
]}, document)
|
||||
report = evaluate_quality(rules, document, {
|
||||
"bbox_mm": {"min": [0, 0, 0], "max": [128, 70, 90]},
|
||||
"topology_records": [
|
||||
{
|
||||
"feature_id": "boss", "owner_feature_ids": ["base"],
|
||||
"geometry": {"bbox_mm": [0, 0, 0, 128, 70, 90]},
|
||||
},
|
||||
{
|
||||
"feature_id": "boss", "owner_feature_ids": ["boss"],
|
||||
"geometry": {"bbox_mm": [60, -29, 16, 68, 29, 74]},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
self.assertEqual(report["status"], "passed")
|
||||
self.assertEqual(report["results"][0]["actual"]["dimensions"], [8.0, 58.0, 58.0])
|
||||
|
||||
def test_feature_diameter_uses_target_circle(self) -> None:
|
||||
document = fixture()
|
||||
document["features"][0]["id"] = "boss"
|
||||
document["features"][0]["sketch_id"] = "boss_sketch"
|
||||
document["geometry"]["sketches"][0]["id"] = "boss_sketch"
|
||||
document["geometry"]["sketches"][0]["profile"] = {"type": "circle", "center": [0, 0], "radius_mm": 29}
|
||||
rules = validate_verification({"rules": [
|
||||
{"id": "boss_dia", "type": "overall_diameter", "feature": "boss", "expected": 58},
|
||||
]}, document)
|
||||
report = evaluate_quality(rules, document, {
|
||||
"bbox_mm": {"min": [0, 0, 0], "max": [128, 70, 90]},
|
||||
})
|
||||
|
||||
self.assertEqual(report["status"], "passed")
|
||||
self.assertEqual(report["results"][0]["actual"], 58.0)
|
||||
|
||||
def test_overall_width_and_height_are_supported(self) -> None:
|
||||
rules = validate_verification({"rules": [
|
||||
{"id": "width", "type": "overall_width", "expected": 70},
|
||||
{"id": "height", "type": "overall_height", "expected": 90},
|
||||
]}, fixture())
|
||||
report = evaluate_quality(rules, fixture(), {
|
||||
"bbox_mm": {"min": [-64, -35, 0], "max": [64, 35, 90]},
|
||||
})
|
||||
|
||||
self.assertEqual(report["status"], "passed")
|
||||
self.assertEqual([item["actual"] for item in report["results"]], [70.0, 90.0])
|
||||
|
||||
def test_feature_scoped_verification_requires_feature(self) -> None:
|
||||
with self.assertRaisesRegex(ValueError, "feature is required for hole_count"):
|
||||
validate_verification({"rules": [
|
||||
{"id": "holes", "type": "hole_count", "expected": 1},
|
||||
]}, fixture())
|
||||
|
||||
def test_bbox_shape_is_rejected_before_execution(self) -> None:
|
||||
with self.assertRaisesRegex(ValueError, "expected for bbox"):
|
||||
validate_verification({"rules": [
|
||||
@@ -211,6 +274,147 @@ class AgentPatchFlowTests(unittest.TestCase):
|
||||
self.assertEqual(captured["operation"]["type"], "cdsl_patch")
|
||||
self.assertEqual(captured["cdsl"]["features"][0]["params"]["distance_mm"], 8)
|
||||
|
||||
def test_completed_feature_can_keep_selector_from_historical_snapshot(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
config = settings(Path(directory))
|
||||
store = WorkspaceStore(config)
|
||||
task_id, parent = self._seed_revision(store)
|
||||
parent_dir = store.task_dir(task_id) / "revisions" / parent
|
||||
parent_snapshot_id = f"{task_id}/{parent}"
|
||||
write_json(parent_dir / "model.topology.json", {
|
||||
"schema_version": "cad.topology.v1",
|
||||
"task_id": task_id,
|
||||
"revision_id": parent,
|
||||
"snapshot_id": parent_snapshot_id,
|
||||
"records": [{"record_id": "body:base:edge:0", "kind": "edge", "executable": True, "geometry": {}}],
|
||||
})
|
||||
task = store.read_task(task_id)
|
||||
task["revisions"][0]["topology_path"] = f"revisions/{parent}/model.topology.json"
|
||||
write_json(store.task_path(task_id), task)
|
||||
|
||||
child = "rev_002"
|
||||
child_dir = store.task_dir(task_id) / "revisions" / child
|
||||
child_dir.mkdir(parents=True)
|
||||
write_json(child_dir / "model.cdsl.json", fixture())
|
||||
write_json(child_dir / "model.topology.json", {
|
||||
"schema_version": "cad.topology.v1",
|
||||
"task_id": task_id,
|
||||
"revision_id": child,
|
||||
"snapshot_id": f"{task_id}/{child}",
|
||||
"records": [{"record_id": "body:base:edge:1", "kind": "edge", "executable": True, "geometry": {}}],
|
||||
})
|
||||
store.update_task(task_id, {
|
||||
"revision_id": child,
|
||||
"status": "success",
|
||||
"cdsl_path": f"revisions/{child}/model.cdsl.json",
|
||||
"topology_path": f"revisions/{child}/model.topology.json",
|
||||
})
|
||||
|
||||
document = fixture()
|
||||
document["features"][0]["selectors"] = [{
|
||||
"kind": "edge",
|
||||
"stable_id": "body:base:edge:0",
|
||||
"source": "runtime_snapshot",
|
||||
"snapshot_id": parent_snapshot_id,
|
||||
"confidence": 1.0,
|
||||
}]
|
||||
_validate_snapshot_selectors(store, task_id, document)
|
||||
|
||||
document["features"][0]["selectors"][0]["snapshot_id"] = f"{task_id}/rev_999"
|
||||
with self.assertRaisesRegex(ValueError, "TOPOLOGY_SNAPSHOT_STALE"):
|
||||
_validate_snapshot_selectors(store, task_id, document)
|
||||
|
||||
def test_topology_inspection_unlocks_waiting_plan_nodes(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
config = settings(Path(directory))
|
||||
store = WorkspaceStore(config)
|
||||
task_id, revision_id = self._seed_revision(store)
|
||||
revision_dir = store.task_dir(task_id) / "revisions" / revision_id
|
||||
snapshot_id = f"{task_id}/{revision_id}"
|
||||
write_json(revision_dir / "model.topology.json", {
|
||||
"schema_version": "cad.topology.v1",
|
||||
"task_id": task_id,
|
||||
"revision_id": revision_id,
|
||||
"snapshot_id": snapshot_id,
|
||||
"records": [{
|
||||
"record_id": "body:base_add",
|
||||
"kind": "body",
|
||||
"body_id": "body:base_add",
|
||||
"feature_id": "base_add",
|
||||
"owner_feature_ids": ["base_add"],
|
||||
"geometry": {},
|
||||
"executable": True,
|
||||
}],
|
||||
})
|
||||
task = store.read_task(task_id)
|
||||
task["revisions"][0]["topology_path"] = f"revisions/{revision_id}/model.topology.json"
|
||||
write_json(store.task_path(task_id), task)
|
||||
state = {
|
||||
"phase": "TOPOLOGY_READY",
|
||||
"feature_plan": {
|
||||
"schema_version": "cad.feature-plan.v1",
|
||||
"plan_id": "plan_1",
|
||||
"task_id": task_id,
|
||||
"nodes": [
|
||||
{"id": "base", "atomic_id": "extrude_add_blind", "depends_on": [], "cdsl_feature_ids": ["base_add"]},
|
||||
{"id": "round", "atomic_id": "fillet", "depends_on": ["base"], "cdsl_feature_ids": ["round"]},
|
||||
],
|
||||
},
|
||||
}
|
||||
agent = AgentService(config, store, CdslLibrary(config), PartSkillLibrary(PART_SKILL_ROOT))
|
||||
|
||||
result, _ = asyncio.run(agent._run_tool(
|
||||
"inspect_current_topology", {}, task_id, "Round the edges", [], planning_state=state,
|
||||
))
|
||||
|
||||
self.assertTrue(result["ok"])
|
||||
self.assertEqual(state["feature_plan"]["topology_snapshot_id"], snapshot_id)
|
||||
self.assertEqual(state["feature_plan"]["ready_nodes"], ["round"])
|
||||
|
||||
def test_successful_build_automatically_binds_topology_for_waiting_nodes(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
config = settings(Path(directory))
|
||||
store = WorkspaceStore(config)
|
||||
agent = AgentService(config, store, CdslLibrary(config), PartSkillLibrary(PART_SKILL_ROOT))
|
||||
state = {
|
||||
"phase": "PLAN_READY",
|
||||
"feature_plan": {
|
||||
"schema_version": "cad.feature-plan.v1",
|
||||
"plan_id": "plan_1",
|
||||
"task_id": "",
|
||||
"nodes": [
|
||||
{
|
||||
"id": "base",
|
||||
"atomic_id": "extrude_add_blind",
|
||||
"depends_on": [],
|
||||
"cdsl_feature_ids": ["base_add"],
|
||||
"status": "ready",
|
||||
},
|
||||
{
|
||||
"id": "round",
|
||||
"atomic_id": "fillet",
|
||||
"depends_on": ["base"],
|
||||
"cdsl_feature_ids": ["round"],
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
result, _ = asyncio.run(agent._run_tool(
|
||||
"generate_cdsl_model",
|
||||
{"cdsl": fixture(), "summary": "Base feature", "assumptions": []},
|
||||
None,
|
||||
"Create a base with a later fillet",
|
||||
[],
|
||||
planning_state=state,
|
||||
))
|
||||
|
||||
self.assertTrue(result["ok"])
|
||||
self.assertEqual(result["required_action"], "patch_cdsl_model")
|
||||
self.assertEqual(result["plan_status"]["ready_nodes"], ["round"])
|
||||
self.assertEqual(result["plan_status"]["waiting_nodes"], [])
|
||||
self.assertEqual(state["feature_plan"]["topology_snapshot_id"], "{}/{}".format(result["task_id"], result["revision_id"]))
|
||||
|
||||
def test_bad_patch_creates_no_revision(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
config = settings(Path(directory))
|
||||
@@ -230,6 +434,31 @@ class AgentPatchFlowTests(unittest.TestCase):
|
||||
task = store.read_task(task_id)
|
||||
self.assertEqual([item["revision_id"] for item in task["revisions"]], [parent])
|
||||
|
||||
def test_patch_rejects_non_current_parent_revision(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
config = settings(Path(directory))
|
||||
store = WorkspaceStore(config)
|
||||
task_id, parent = self._seed_revision(store)
|
||||
child = "rev_002"
|
||||
child_dir = store.task_dir(task_id) / "revisions" / child
|
||||
child_dir.mkdir(parents=True)
|
||||
write_json(child_dir / "model.cdsl.json", fixture())
|
||||
store.update_task(task_id, {
|
||||
"revision_id": child,
|
||||
"status": "success",
|
||||
"cdsl_path": f"revisions/{child}/model.cdsl.json",
|
||||
})
|
||||
agent = AgentService(config, store, CdslLibrary(config), PartSkillLibrary(PART_SKILL_ROOT))
|
||||
with self.assertRaisesRegex(ValueError, "TOPOLOGY_SNAPSHOT_STALE"):
|
||||
asyncio.run(agent._run_tool(
|
||||
"patch_cdsl_model",
|
||||
{"base_revision_id": parent, "patches": [{"op": "replace", "path": "/features/0/params/distance_mm", "value": 8}], "summary": "stale", "assumptions": []},
|
||||
task_id,
|
||||
"stale patch",
|
||||
[],
|
||||
planning_state={"phase": "PLANNED"},
|
||||
))
|
||||
|
||||
def test_complete_replacement_uses_current_revision_as_parent(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
config = settings(Path(directory))
|
||||
|
||||
@@ -193,6 +193,37 @@ class EngineRuntimeFoundationTests(unittest.TestCase):
|
||||
self.assertEqual(resolution.status, "ambiguous")
|
||||
self.assertEqual(resolution.diagnostic.code, "selector_ambiguous")
|
||||
|
||||
def test_selector_resolver_prefers_exact_runtime_stable_id(self) -> None:
|
||||
registry = TopologyRegistry()
|
||||
geometry = {"curve_type": "circle", "center_mm": [0, 0, 0]}
|
||||
registry.register(TopologyRecord("body:b:edge:18", "edge", "boss", "body:b", geometry))
|
||||
registry.register(TopologyRecord("body:b:edge:20", "edge", "boss", "body:b", geometry))
|
||||
|
||||
resolution = registry.resolve({
|
||||
"kind": "edge",
|
||||
"stable_id": "body:b:edge:18",
|
||||
"owner_feature_id": "boss",
|
||||
"snapshot_id": "cad_test/rev_001",
|
||||
"geometry": geometry,
|
||||
}, active_body_id="body:b")
|
||||
|
||||
self.assertEqual(resolution.status, "resolved")
|
||||
self.assertEqual(resolution.record.record_id, "body:b:edge:18")
|
||||
|
||||
def test_snapshot_stable_id_rejects_geometry_that_changed(self) -> None:
|
||||
registry = TopologyRegistry()
|
||||
registry.register(TopologyRecord(
|
||||
"body:b:edge:18", "edge", "boss", "body:b",
|
||||
{"curve_type": "circle", "center_mm": [0, 0, 0]},
|
||||
))
|
||||
resolution = registry.resolve({
|
||||
"kind": "edge", "stable_id": "body:b:edge:18", "owner_feature_id": "boss",
|
||||
"snapshot_id": "cad_test/rev_001",
|
||||
"geometry": {"curve_type": "circle", "center_mm": [1, 0, 0]},
|
||||
}, active_body_id="body:b")
|
||||
self.assertEqual(resolution.status, "not_found")
|
||||
self.assertEqual(resolution.diagnostic.code, "selector_geometry_mismatch")
|
||||
|
||||
def test_selector_resolver_normalizes_legacy_solidworks_plane_evidence(self) -> None:
|
||||
registry = TopologyRegistry()
|
||||
registry.register(TopologyRecord(
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "backend"))
|
||||
|
||||
from app.services.feature_plan import FeaturePlanError, compute_node_statuses, validate_feature_plan # noqa: E402
|
||||
|
||||
|
||||
class FeaturePlanTests(unittest.TestCase):
|
||||
def plan(self) -> dict:
|
||||
return {
|
||||
"schema_version": "cad.feature-plan.v1",
|
||||
"plan_id": "plan_test",
|
||||
"task_id": "cad_test",
|
||||
"nodes": [
|
||||
{"id": "base", "atomic_id": "extrude_add_blind", "depends_on": []},
|
||||
{"id": "round", "atomic_id": "fillet", "depends_on": ["base"], "requires_topology": True},
|
||||
],
|
||||
}
|
||||
|
||||
def test_rejects_cycles_and_missing_dependencies(self) -> None:
|
||||
cyclic = self.plan()
|
||||
cyclic["nodes"][0]["depends_on"] = ["round"]
|
||||
with self.assertRaises(FeaturePlanError):
|
||||
validate_feature_plan(cyclic, supported_atomic_ids={"extrude_add_blind", "fillet"})
|
||||
missing = self.plan()
|
||||
missing["nodes"][1]["depends_on"] = ["missing"]
|
||||
with self.assertRaises(FeaturePlanError):
|
||||
validate_feature_plan(missing, supported_atomic_ids={"extrude_add_blind", "fillet"})
|
||||
|
||||
def test_ready_prefix_waits_for_topology(self) -> None:
|
||||
statuses = compute_node_statuses(self.plan(), cdsl={"features": []})
|
||||
self.assertEqual(statuses["ready_nodes"], ["base"])
|
||||
self.assertEqual(statuses["waiting_nodes"], [])
|
||||
|
||||
def test_built_prefix_unlocks_topology_dependent_node(self) -> None:
|
||||
statuses = compute_node_statuses(
|
||||
self.plan(),
|
||||
cdsl={"features": [{"id": "base"}]},
|
||||
topology={"records": [{"record_id": "body:base", "kind": "body", "executable": True}]},
|
||||
)
|
||||
self.assertEqual(statuses["completed_nodes"], ["base"])
|
||||
self.assertEqual(statuses["waiting_nodes"], ["round"])
|
||||
|
||||
def test_inspected_snapshot_unlocks_topology_dependent_node(self) -> None:
|
||||
plan = self.plan()
|
||||
plan["topology_snapshot_id"] = "cad_test/rev_001"
|
||||
statuses = compute_node_statuses(
|
||||
plan,
|
||||
cdsl={"features": [{"id": "base"}]},
|
||||
topology={
|
||||
"snapshot_id": "cad_test/rev_001",
|
||||
"records": [{"record_id": "body:base", "kind": "body", "executable": True}],
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(statuses["ready_nodes"], ["round"])
|
||||
self.assertEqual(statuses["waiting_nodes"], [])
|
||||
|
||||
def test_dependency_must_appear_before_dependent_node(self) -> None:
|
||||
plan = self.plan()
|
||||
plan["nodes"] = [plan["nodes"][1], plan["nodes"][0]]
|
||||
with self.assertRaisesRegex(FeaturePlanError, "appear after dependency"):
|
||||
validate_feature_plan(plan, supported_atomic_ids={"extrude_add_blind", "fillet"})
|
||||
@@ -0,0 +1,134 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from app.services.image_observation import (
|
||||
merge_image_observations,
|
||||
normalize_image_observation,
|
||||
normalize_sketch_candidates,
|
||||
render_image_observation_context,
|
||||
)
|
||||
from app.services.image_processing import cv_hints, image_metadata
|
||||
from app.services.agent_service import tools_for_model
|
||||
from app.services.agent_service import AgentService
|
||||
from app.services.attachments import attachment_record
|
||||
from app.services.library import CdslLibrary
|
||||
from app.services.storage import WorkspaceStore
|
||||
from app.settings import ProviderConfig, ProviderModel, Settings
|
||||
from app.models.contracts import ChatMessage, MessagePart
|
||||
|
||||
|
||||
def test_observation_keeps_multiview_profiles_and_measurement_sources() -> None:
|
||||
result = normalize_image_observation({
|
||||
"part_type": "bent bracket",
|
||||
"visible_features": ["plate", "irregular opening"],
|
||||
"uncertain_features": ["inner bend radius"],
|
||||
"views": [{"attachment_id": "upload_a", "view_role": "front", "confidence": 0.8}],
|
||||
"profiles": [{
|
||||
"id": "opening_01",
|
||||
"role": "cutout",
|
||||
"closed": True,
|
||||
"source_images": ["upload_a"],
|
||||
"segments": [
|
||||
{"type": "line", "start": [0, 0], "end": [10, 0]},
|
||||
{"type": "arc", "start": [10, 0], "end": [10, 4], "center": [8, 2], "radius_mm": 2},
|
||||
{"type": "polyline", "points": [[10, 4], [5, 8], [0, 4]]},
|
||||
],
|
||||
}],
|
||||
"measurements": [{"name": "plate_thickness", "value_mm": 1.2, "source": "user"}],
|
||||
}, attachment_ids=["upload_a"])
|
||||
|
||||
assert result["schema_version"] == "cad.image-observation.v2"
|
||||
assert result["profiles"][0]["segments"][1]["type"] == "arc"
|
||||
assert result["measurements"][0]["source"] == "user"
|
||||
assert "opening_01" in render_image_observation_context(result)
|
||||
|
||||
|
||||
def test_sketch_merge_does_not_replace_user_measurement() -> None:
|
||||
survey = normalize_image_observation({
|
||||
"part_type": "bracket",
|
||||
"visible_features": ["plate"],
|
||||
"uncertain_features": [],
|
||||
"views": [{"attachment_id": "upload_a"}],
|
||||
"measurements": [{"name": "thickness", "value_mm": 1.2, "source": "user"}],
|
||||
}, attachment_ids=["upload_a"])
|
||||
sketches = normalize_sketch_candidates({
|
||||
"profiles": [],
|
||||
"measurements": [{"name": "thickness", "value_mm": 1.6, "source": "image"}],
|
||||
"uncertainties": ["bend radius"],
|
||||
}, attachment_ids=["upload_a"])
|
||||
merged = merge_image_observations(survey, sketches)
|
||||
|
||||
assert merged["measurements"][0]["value_mm"] == 1.2
|
||||
assert merged["uncertainties"] == ["bend radius"]
|
||||
|
||||
|
||||
def test_image_metadata_and_cv_degrade_without_required_cv_support() -> None:
|
||||
output = io.BytesIO()
|
||||
Image.new("RGB", (32, 16), "white").save(output, format="PNG")
|
||||
metadata = image_metadata(output.getvalue())
|
||||
assert metadata["width"] == 32
|
||||
assert metadata["height"] == 16
|
||||
assert "available" in cv_hints(output.getvalue())
|
||||
|
||||
|
||||
def test_image_tool_stages_expose_only_the_required_tool() -> None:
|
||||
model = ProviderModel("vision", vision=True)
|
||||
assert [tool["function"]["name"] for tool in tools_for_model(model, image_stage="survey")] == ["analyze_image_reference"]
|
||||
assert [tool["function"]["name"] for tool in tools_for_model(model, image_stage="sketch", include_image_analysis=False, include_image_sketches=True)] == ["extract_image_sketch_candidates"]
|
||||
|
||||
|
||||
def test_agent_runs_survey_then_sketch_stage_before_normal_tools() -> None:
|
||||
class TwoStageAgent(AgentService):
|
||||
def __init__(self, *args: object, **kwargs: object) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
self.required: list[str | None] = []
|
||||
self.responses = [
|
||||
{"choices": [{"message": {"role": "assistant", "content": "", "tool_calls": [{
|
||||
"id": "survey", "type": "function", "function": {"name": "analyze_image_reference", "arguments": json.dumps({
|
||||
"part_type": "bracket", "visible_features": ["plate"], "uncertain_features": [],
|
||||
"views": [{"attachment_id": "upload_a", "view_role": "front"}], "profiles": [],
|
||||
"measurements": [], "uncertainties": [],
|
||||
})},
|
||||
}]}}]},
|
||||
{"choices": [{"message": {"role": "assistant", "content": "", "tool_calls": [{
|
||||
"id": "sketch", "type": "function", "function": {"name": "extract_image_sketch_candidates", "arguments": json.dumps({
|
||||
"profiles": [{"id": "opening", "role": "cutout", "closed": True, "segments": [{"type": "line", "start": [0, 0], "end": [2, 0]}]}],
|
||||
"measurements": [], "uncertainties": [],
|
||||
})},
|
||||
}]}}]},
|
||||
{"choices": [{"message": {"role": "assistant", "content": "继续建模。", "tool_calls": []}}]},
|
||||
]
|
||||
|
||||
async def _complete(self, *args: object, **kwargs: object) -> dict[str, object]:
|
||||
self.required.append(kwargs.get("required_tool_name") if "required_tool_name" in kwargs else args[4] if len(args) > 4 else None)
|
||||
return self.responses.pop(0)
|
||||
|
||||
with TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
backend_root = Path(__file__).resolve().parents[1]
|
||||
provider = ProviderConfig("test", "Test", "https://example.invalid/v1", "key", (ProviderModel("vision", vision=True),))
|
||||
settings = Settings(root / "tasks", root / "conversations", backend_root / "cdsl_library", backend_root / "engine" / "cdsl_engine", "", "", "", 5, "test", (provider,))
|
||||
store = WorkspaceStore(settings)
|
||||
conversation_id = "conv_000000000001"
|
||||
store.ensure_conversation(conversation_id)
|
||||
relative, _ = store.write_conversation_upload(conversation_id, "part.png", b"png")
|
||||
store.add_conversation_attachment(conversation_id, attachment_record(conversation_id, "part.png", "image/png", relative, b"png", "image"))
|
||||
agent = TwoStageAgent(settings, store, CdslLibrary(settings))
|
||||
message = ChatMessage(id="user", role="user", parts=[MessagePart(type="text", text="根据图片继续建模")])
|
||||
|
||||
async def collect() -> list[dict[str, object]]:
|
||||
events = []
|
||||
async for chunk in agent.stream([message], conversation_id, None):
|
||||
events.append(json.loads(chunk.decode("utf-8").split("data: ", 1)[1]))
|
||||
return events
|
||||
|
||||
events = asyncio.run(collect())
|
||||
assert agent.required[:2] == ["analyze_image_reference", "extract_image_sketch_candidates"]
|
||||
assert any(event.get("observationStage") == "complete" for event in events)
|
||||
@@ -0,0 +1,340 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from dataclasses import replace
|
||||
import json
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "backend"))
|
||||
|
||||
from fastapi import HTTPException # noqa: E402
|
||||
from app import main as api # noqa: E402
|
||||
from app.services.cdsl_fragment import CdslFragmentError, cdsl_sha256, validate_fragment # noqa: E402
|
||||
from app.services.generation_plan import GenerationPlanError, descendant_closure, mark_nodes_stale, validate_generation_plan # noqa: E402
|
||||
from app.services.incremental_generation import IncrementalGenerationRunner # noqa: E402
|
||||
from app.services.review_renderer import CANONICAL_VIEWS, REVIEW_SIZE, RENDER_SIZE, render_checkpoint # noqa: E402
|
||||
from app.services.storage import WorkspaceStore # noqa: E402
|
||||
from app.services.visual_review import _selected_review_views # noqa: E402
|
||||
from app.settings import ProviderConfig, ProviderModel, Settings # noqa: E402
|
||||
|
||||
|
||||
def plan() -> dict:
|
||||
return {
|
||||
"schema_version": "cad.generation-plan.v2",
|
||||
"plan_id": "incremental_test",
|
||||
"requirements": [
|
||||
{"id": "req_base", "source": "explicit", "priority": "hard", "description": "base solid"},
|
||||
{"id": "req_round", "source": "explicit", "priority": "hard", "description": "edge round"},
|
||||
],
|
||||
"assumptions": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": "base", "intent": "base", "atomic_id": "extrude_add_blind", "depends_on": [],
|
||||
"requirement_ids": ["req_base"], "verification_rules": [], "review_targets": [],
|
||||
},
|
||||
{
|
||||
"id": "round", "intent": "round", "atomic_id": "fillet", "depends_on": ["base"],
|
||||
"requires_topology": True,
|
||||
"requirement_ids": ["req_round"], "verification_rules": [], "review_targets": [],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
class GenerationPlanTests(unittest.TestCase):
|
||||
def test_hard_requirements_and_backend_outputs_are_owned(self) -> None:
|
||||
invalid = plan()
|
||||
invalid["nodes"][1]["requirement_ids"] = []
|
||||
with self.assertRaisesRegex(GenerationPlanError, "Hard requirements"):
|
||||
validate_generation_plan(invalid, supported_atomic_ids={"extrude_add_blind", "fillet"}, task_id="cad_abcdef123456")
|
||||
|
||||
generated = validate_generation_plan(plan(), supported_atomic_ids={"extrude_add_blind", "fillet"}, task_id="cad_abcdef123456")
|
||||
self.assertEqual(generated["id_strategy"], "backend-derived-v1")
|
||||
self.assertEqual(len(generated["nodes"][0]["cdsl_feature_ids"]), 1)
|
||||
self.assertEqual(len(generated["nodes"][0]["cdsl_sketch_ids"]), 1)
|
||||
self.assertEqual(generated["nodes"][1]["cdsl_sketch_ids"], [])
|
||||
# Legacy fields from an untrusted model output cannot choose CDSL IDs.
|
||||
supplied = plan()
|
||||
supplied["nodes"][0]["expected_feature_ids"] = ["model_chosen_id"]
|
||||
self.assertEqual(
|
||||
validate_generation_plan(supplied, supported_atomic_ids={"extrude_add_blind", "fillet"}, task_id="cad_abcdef123456")["nodes"][0]["cdsl_feature_ids"],
|
||||
generated["nodes"][0]["cdsl_feature_ids"],
|
||||
)
|
||||
|
||||
def test_upstream_invalidation_marks_all_descendants_stale(self) -> None:
|
||||
spec = validate_generation_plan(plan(), supported_atomic_ids={"extrude_add_blind", "fillet"}, task_id="cad_abcdef123456")
|
||||
spec["nodes"][0]["status"] = "completed"
|
||||
spec["nodes"][1]["status"] = "completed"
|
||||
self.assertEqual(descendant_closure(spec, "base"), {"base", "round"})
|
||||
stale = mark_nodes_stale(spec, "base", reason="topology_changed")
|
||||
self.assertTrue(all(node["stale"] for node in stale["nodes"]))
|
||||
self.assertTrue(all(node["status"] == "planned" for node in stale["nodes"]))
|
||||
|
||||
|
||||
class FragmentTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.spec = validate_generation_plan(plan(), supported_atomic_ids={"extrude_add_blind", "fillet"}, task_id="cad_abcdef123456")
|
||||
|
||||
def base_fragment(self) -> dict:
|
||||
return {
|
||||
"schema_version": "cad.cdsl-fragment.v1", "node_id": "base", "base_revision_id": "",
|
||||
"base_cdsl_sha256": cdsl_sha256(None), "add_sketches": [{}],
|
||||
"add_features": [{}], "verification_rules": [], "assumptions": [],
|
||||
}
|
||||
|
||||
def test_fragment_assigns_ids_dependencies_and_atomic_from_plan(self) -> None:
|
||||
accepted = validate_fragment(self.base_fragment(), plan=self.spec, node_id="base", base_revision_id="", base_cdsl=None)
|
||||
base = self.spec["nodes"][0]
|
||||
self.assertEqual(accepted["add_sketches"][0]["id"], base["cdsl_sketch_ids"][0])
|
||||
self.assertEqual(accepted["add_features"][0]["id"], base["cdsl_feature_ids"][0])
|
||||
self.assertEqual(accepted["add_features"][0]["sketch_id"], base["cdsl_sketch_ids"][0])
|
||||
self.assertEqual(accepted["add_features"][0]["atomic_id"], "extrude_add_blind")
|
||||
self.assertEqual(accepted["add_features"][0]["depends_on"], [])
|
||||
model_ids = self.base_fragment()
|
||||
model_ids["add_sketches"][0]["id"] = "model_sketch"
|
||||
model_ids["add_features"][0].update({"id": "model_feature", "atomic_id": "fillet", "sketch_id": "model_sketch"})
|
||||
overwritten = validate_fragment(model_ids, plan=self.spec, node_id="base", base_revision_id="", base_cdsl=None)
|
||||
self.assertEqual(overwritten["add_features"][0]["id"], base["cdsl_feature_ids"][0])
|
||||
self.assertEqual(overwritten["add_features"][0]["atomic_id"], "extrude_add_blind")
|
||||
|
||||
def test_fragment_rejects_wrong_hash_and_output_shape(self) -> None:
|
||||
wrong_hash = self.base_fragment()
|
||||
wrong_hash["base_cdsl_sha256"] = "0" * 64
|
||||
with self.assertRaisesRegex(CdslFragmentError, "sha256"):
|
||||
validate_fragment(wrong_hash, plan=self.spec, node_id="base", base_revision_id="", base_cdsl=None)
|
||||
unexpected_sketch = self.base_fragment()
|
||||
unexpected_sketch["add_sketches"].append({"id": "extra"})
|
||||
with self.assertRaisesRegex(CdslFragmentError, "one new sketch"):
|
||||
validate_fragment(unexpected_sketch, plan=self.spec, node_id="base", base_revision_id="", base_cdsl=None)
|
||||
|
||||
def test_topology_fragment_requires_active_snapshot_owner_and_geometry(self) -> None:
|
||||
base = self.spec["nodes"][0]
|
||||
round_node = self.spec["nodes"][1]
|
||||
base_cdsl = {
|
||||
"geometry": {"sketches": [{"id": base["cdsl_sketch_ids"][0]}]},
|
||||
"features": [{"id": base["cdsl_feature_ids"][0], "atomic_id": "extrude_add_blind"}],
|
||||
}
|
||||
fragment = {
|
||||
"schema_version": "cad.cdsl-fragment.v1", "node_id": "round", "base_revision_id": "rev_001",
|
||||
"base_cdsl_sha256": cdsl_sha256(base_cdsl), "required_snapshot_id": "cad_test/rev_001",
|
||||
"add_sketches": [],
|
||||
"add_features": [{
|
||||
"selectors": [{
|
||||
"kind": "edge", "stable_id": "body:feature_base:edge:0", "owner_node_id": "base",
|
||||
"snapshot_id": "cad_test/rev_001", "geometry": {"curve_type": "line", "length_mm": 10},
|
||||
}],
|
||||
}],
|
||||
"verification_rules": [], "assumptions": [],
|
||||
}
|
||||
accepted = validate_fragment(
|
||||
fragment, plan=self.spec, node_id="round", base_revision_id="rev_001", base_cdsl=base_cdsl,
|
||||
required_snapshot_id="cad_test/rev_001",
|
||||
)
|
||||
self.assertEqual(accepted["node_id"], "round")
|
||||
self.assertEqual(accepted["add_features"][0]["id"], round_node["cdsl_feature_ids"][0])
|
||||
self.assertEqual(accepted["add_features"][0]["depends_on"], [base["cdsl_feature_ids"][0]])
|
||||
self.assertEqual(accepted["add_features"][0]["selectors"][0]["owner_feature_id"], base["cdsl_feature_ids"][0])
|
||||
fragment["add_features"][0]["selectors"][0].pop("geometry")
|
||||
with self.assertRaisesRegex(CdslFragmentError, "geometry signature"):
|
||||
validate_fragment(
|
||||
fragment, plan=self.spec, node_id="round", base_revision_id="rev_001", base_cdsl=base_cdsl,
|
||||
required_snapshot_id="cad_test/rev_001",
|
||||
)
|
||||
|
||||
|
||||
class StorageAndRunnerTests(unittest.TestCase):
|
||||
def settings(self, root: Path) -> Settings:
|
||||
provider = ProviderConfig("author", "Author", "https://example.invalid/v1", "secret", (ProviderModel("author-model"),))
|
||||
return Settings(
|
||||
task_root=root / "tasks", conversation_root=root / "conversations", library_root=root / "library",
|
||||
engine_root=ROOT / "backend" / "engine" / "cdsl_engine", llm_base_url="", llm_api_key="", llm_model="author-model",
|
||||
llm_timeout_s=1, default_provider_id="author", providers=(provider,), incremental_generation=True,
|
||||
)
|
||||
|
||||
def test_rollback_anchor_rewinds_before_affected_nodes(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
store = WorkspaceStore(self.settings(Path(directory)))
|
||||
task = store.ensure_task(None, "test")
|
||||
task_id = task["task_id"]
|
||||
for revision_id, parent, node_id in (("rev_001", "", "base"), ("rev_002", "rev_001", "middle"), ("rev_003", "rev_002", "tip")):
|
||||
store.update_task(task_id, {"revision_id": revision_id, "status": "success", "parent_revision_id": parent, "node_id": node_id, "visibility": "checkpoint"})
|
||||
self.assertEqual(store.rollback_anchor_for_nodes(task_id, ["middle", "tip"], fallback_revision_id="rev_002"), "rev_001")
|
||||
self.assertEqual(store.rollback_anchor_for_nodes(task_id, ["base"], fallback_revision_id="rev_001"), "")
|
||||
store.rollback_to_revision(task_id, "rev_002", branch_id="branch_repair")
|
||||
revisions = {item["revision_id"]: item for item in (store.read_task(task_id) or {})["revisions"]}
|
||||
self.assertEqual(revisions["rev_001"]["visibility"], "checkpoint")
|
||||
self.assertEqual(revisions["rev_002"]["visibility"], "checkpoint")
|
||||
self.assertEqual(revisions["rev_003"]["visibility"], "superseded")
|
||||
|
||||
def test_run_context_is_persisted_for_worker_recovery(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
store = WorkspaceStore(self.settings(Path(directory)))
|
||||
task = store.ensure_task(None, "test")
|
||||
store.write_generation_run_context(task["task_id"], {
|
||||
"schema_version": "cad.generation-run-context.v1",
|
||||
"request": "test", "conversation_id": "conv_abcdef123456",
|
||||
"provider_id": "author", "model_id": "author-model", "author_messages": [], "part_skills": {},
|
||||
})
|
||||
recovered = store.read_generation_run_context(task["task_id"])
|
||||
self.assertEqual(recovered and recovered["request"], "test")
|
||||
task = store.start_generation(task["task_id"], request="test")
|
||||
self.assertEqual([item["task_id"] for item in store.running_tasks()], [task["task_id"]])
|
||||
|
||||
def test_missing_visual_review_configuration_fails_the_run(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
settings = self.settings(Path(directory))
|
||||
store = WorkspaceStore(settings)
|
||||
task = store.ensure_task(None, "test")
|
||||
|
||||
async def complete(*_args: object) -> dict:
|
||||
raise AssertionError("author must not be called before visual configuration validation")
|
||||
|
||||
runner = IncrementalGenerationRunner(settings, store, complete)
|
||||
|
||||
async def collect() -> list[tuple[str, dict]]:
|
||||
return [item async for item in runner.run(
|
||||
task_id=task["task_id"], request="test", conversation={"conversation_id": "", "attachments": []},
|
||||
provider=settings.providers[0], model=settings.providers[0].models[0], author_messages=[],
|
||||
)]
|
||||
|
||||
events = asyncio.run(collect())
|
||||
self.assertEqual(events[-1][0], "task_terminal")
|
||||
self.assertEqual(events[-1][1]["lifecycle"], "failed")
|
||||
self.assertEqual((store.read_task(task["task_id"]) or {})["lifecycle"], "failed")
|
||||
|
||||
def test_invalid_plan_is_preserved_in_failure_diagnostics(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
provider = ProviderConfig(
|
||||
"author", "Author", "https://example.invalid/v1", "secret",
|
||||
(ProviderModel("author-model", vision=True),),
|
||||
)
|
||||
settings = replace(
|
||||
self.settings(root), providers=(provider,), review_provider_id="author", review_model_id="author-model",
|
||||
)
|
||||
store = WorkspaceStore(settings)
|
||||
task = store.ensure_task(None, "test")
|
||||
raw_plan = {"schema_version": "cad.generation-plan.v2", "plan_id": "broken", "requirements": []}
|
||||
|
||||
async def complete(*_args: object) -> dict:
|
||||
return {
|
||||
"choices": [{"message": {"tool_calls": [{"function": {
|
||||
"name": "plan_generation_task", "arguments": json.dumps(raw_plan),
|
||||
}}]}}],
|
||||
}
|
||||
|
||||
runner = IncrementalGenerationRunner(settings, store, complete)
|
||||
engine = type("Engine", (), {"SUPPORTED_ATOMIC_IDS": ("extrude_add_blind",)})()
|
||||
|
||||
async def collect() -> list[tuple[str, dict]]:
|
||||
return [item async for item in runner.run(
|
||||
task_id=task["task_id"], request="test", conversation={"conversation_id": "", "attachments": []},
|
||||
provider=provider, model=provider.models[0], author_messages=[],
|
||||
)]
|
||||
|
||||
with patch("app.services.incremental_generation.load_engine", return_value=engine), patch(
|
||||
"app.services.incremental_generation.renderer_status", return_value=(True, "")
|
||||
):
|
||||
events = asyncio.run(collect())
|
||||
|
||||
self.assertEqual(events[-1], ("task_terminal", {
|
||||
"taskId": task["task_id"], "lifecycle": "failed",
|
||||
"message": "Generation plan requires a non-empty requirements array",
|
||||
}))
|
||||
failed_task = store.read_task(task["task_id"]) or {}
|
||||
failure_path = root / "tasks" / task["task_id"] / str(failed_task["run_failure_path"])
|
||||
with failure_path.open(encoding="utf-8") as handle:
|
||||
failure = json.load(handle)
|
||||
diagnostic_path = root / "tasks" / task["task_id"] / str(failure["plan_diagnostic_path"])
|
||||
with diagnostic_path.open(encoding="utf-8") as handle:
|
||||
diagnostic = json.load(handle)
|
||||
self.assertEqual(diagnostic["stage"], "generation_plan_validation")
|
||||
self.assertEqual(diagnostic["raw_plan"], raw_plan)
|
||||
|
||||
|
||||
class ArtifactAccessTests(unittest.TestCase):
|
||||
def test_checkpoint_allows_only_the_active_glb_until_publication(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
store = WorkspaceStore(StorageAndRunnerTests().settings(Path(directory)))
|
||||
task = store.ensure_task(None, "test")
|
||||
task_id = task["task_id"]
|
||||
revision_id, revision_dir = store.next_revision(task_id)
|
||||
glb = revision_dir / "model.glb"
|
||||
step = revision_dir / "model.step"
|
||||
glb.write_bytes(b"glb")
|
||||
step.write_bytes(b"step")
|
||||
store.update_task(task_id, {
|
||||
"revision_id": revision_id,
|
||||
"status": "success",
|
||||
"cdsl_path": f"revisions/{revision_id}/model.cdsl.json",
|
||||
"glb_path": f"revisions/{revision_id}/model.glb",
|
||||
"step_path": f"revisions/{revision_id}/model.step",
|
||||
"report_path": f"revisions/{revision_id}/rebuild-report.json",
|
||||
"visibility": "checkpoint",
|
||||
})
|
||||
previous_store = api.store
|
||||
api.store = store
|
||||
try:
|
||||
response = asyncio.run(api.read_artifact(task_id, f"revisions/{revision_id}/model.glb"))
|
||||
self.assertEqual(response.media_type, "model/gltf-binary")
|
||||
with self.assertRaises(HTTPException) as rejected:
|
||||
asyncio.run(api.read_artifact(task_id, f"revisions/{revision_id}/model.step"))
|
||||
self.assertEqual(rejected.exception.status_code, 403)
|
||||
store.finish_generation(task_id, lifecycle="completed")
|
||||
response = asyncio.run(api.read_artifact(task_id, f"revisions/{revision_id}/model.step"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
finally:
|
||||
api.store = previous_store
|
||||
|
||||
|
||||
class TechnicalRenderTests(unittest.TestCase):
|
||||
def test_cpu_renderer_emits_fixed_views_and_keeps_detail_separate(self) -> None:
|
||||
from build123d import Box, export_step
|
||||
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
step_path = root / "box.step"
|
||||
export_step(Box(30, 20, 10), step_path)
|
||||
manifest = render_checkpoint(
|
||||
StorageAndRunnerTests().settings(root),
|
||||
step_path=step_path,
|
||||
output_dir=root / "review",
|
||||
review_targets=[{"bbox_mm": [-5, -5, -5, 5, 5, 5]}],
|
||||
)
|
||||
self.assertEqual(manifest["renderer"], "python-occ-hlr-pillow")
|
||||
views = {item["id"]: item for item in manifest["views"]}
|
||||
self.assertEqual(set(CANONICAL_VIEWS), set(views) & set(CANONICAL_VIEWS))
|
||||
self.assertIn("detail-1", views)
|
||||
self.assertNotEqual(views["isometric"]["path"], views["detail-1"]["path"])
|
||||
self.assertTrue(all(Path(views[view_id]["path"]).is_file() for view_id in CANONICAL_VIEWS))
|
||||
self.assertTrue(all(views[view_id]["diagnostics"]["valid"] for view_id in CANONICAL_VIEWS))
|
||||
self.assertTrue(views["detail-1"]["diagnostics"]["intentional_crop"])
|
||||
from PIL import Image
|
||||
|
||||
with Image.open(views["isometric"]["path"]) as image:
|
||||
self.assertEqual(image.size, (REVIEW_SIZE, REVIEW_SIZE))
|
||||
with Image.open(views["isometric"]["high_resolution_path"]) as image:
|
||||
self.assertEqual(image.size, (RENDER_SIZE, RENDER_SIZE))
|
||||
|
||||
def test_routine_visual_review_uses_compact_evidence(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
contact = root / "contact-sheet.jpg"
|
||||
contact.write_bytes(b"jpg")
|
||||
manifest = {
|
||||
"contact_sheet_path": str(contact),
|
||||
"views": [{"id": view_id, "path": str(root / f"{view_id}.png")} for view_id in (*CANONICAL_VIEWS, "detail-1", "detail-2")],
|
||||
}
|
||||
routine = _selected_review_views(manifest, final_checkpoint=False)
|
||||
final = _selected_review_views(manifest, final_checkpoint=True)
|
||||
self.assertEqual([item["id"] for item in routine], ["contact-sheet", "detail-1", "detail-2"])
|
||||
self.assertEqual({item["id"] for item in final}, {"contact-sheet", "detail-1", "detail-2", *CANONICAL_VIEWS})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -242,7 +242,7 @@ class AgentPartSkillTests(unittest.TestCase):
|
||||
|
||||
self.assertIn("[planning/flange]", prompt)
|
||||
self.assertIn("circular-pattern atomic", prompt)
|
||||
self.assertEqual([tool["function"]["name"] for tool in TOOL_SCHEMAS], ["analyze_image_reference", "search_cdsl_library", "read_cdsl_reference", "describe_design_intent", "read_current_cdsl", "generate_cdsl_model", "patch_cdsl_model"])
|
||||
self.assertEqual([tool["function"]["name"] for tool in TOOL_SCHEMAS], ["analyze_image_reference", "extract_image_sketch_candidates", "search_cdsl_library", "read_cdsl_reference", "describe_design_intent", "read_current_cdsl", "generate_cdsl_model", "patch_cdsl_model"])
|
||||
|
||||
def test_generation_persists_assumptions_and_selected_skills(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "backend"))
|
||||
|
||||
from app.services.engine_service import topology_snapshot, topology_sidecars # noqa: E402
|
||||
|
||||
|
||||
class TopologySnapshotTests(unittest.TestCase):
|
||||
def result(self) -> dict:
|
||||
return {
|
||||
"topology_records": [
|
||||
{
|
||||
"record_id": "body:base:edge:0",
|
||||
"kind": "edge",
|
||||
"feature_id": "base",
|
||||
"body_id": "body:base",
|
||||
"owner_feature_ids": ["base"],
|
||||
"geometry": {"curve_type": "line", "length_mm": 10},
|
||||
},
|
||||
{
|
||||
"record_id": "body:base",
|
||||
"kind": "body",
|
||||
"feature_id": "base",
|
||||
"body_id": "body:base",
|
||||
"geometry": {},
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
def test_snapshot_preserves_runtime_edges(self) -> None:
|
||||
snapshot = topology_snapshot(self.result(), task_id="cad_test", revision_id="rev_001")
|
||||
self.assertEqual(snapshot["snapshot_id"], "cad_test/rev_001")
|
||||
self.assertEqual(snapshot["records"][0]["record_id"], "body:base:edge:0")
|
||||
self.assertTrue(snapshot["records"][0]["executable"])
|
||||
|
||||
def test_sidecars_are_derived_from_runtime_records(self) -> None:
|
||||
snapshot = topology_snapshot(self.result())
|
||||
selector, edges = topology_sidecars(self.result(), snapshot=snapshot)
|
||||
self.assertEqual(edges["edges"][0]["record_id"], "body:base:edge:0")
|
||||
self.assertEqual(selector["edges"][0]["source"], "runtime_snapshot")
|
||||
|
||||
def test_snapshot_excludes_superseded_body_records(self) -> None:
|
||||
result = {
|
||||
"feature_results": [
|
||||
{"feature_id": "base", "body_id": "body:base"},
|
||||
{"feature_id": "cut", "body_id": "body:cut"},
|
||||
],
|
||||
"topology_records": [
|
||||
{"record_id": "body:base", "kind": "body", "body_id": "body:base", "feature_id": "base"},
|
||||
{"record_id": "body:base:edge:0", "kind": "edge", "body_id": "body:base", "feature_id": "base"},
|
||||
{"record_id": "body:cut", "kind": "body", "body_id": "body:cut", "feature_id": "cut"},
|
||||
{"record_id": "body:cut:edge:0", "kind": "edge", "body_id": "body:cut", "feature_id": "cut"},
|
||||
],
|
||||
}
|
||||
|
||||
snapshot = topology_snapshot(result)
|
||||
|
||||
self.assertEqual(snapshot["body_id"], "body:cut")
|
||||
self.assertEqual([record["record_id"] for record in snapshot["records"]], ["body:cut", "body:cut:edge:0"])
|
||||
|
||||
def test_preview_faces_are_audited_but_not_executable(self) -> None:
|
||||
snapshot = topology_snapshot(
|
||||
{"topology_records": []},
|
||||
task_id="cad_test",
|
||||
revision_id="rev_001",
|
||||
preview={"topology_faces": [{"id": "preview_face", "surface_type": "plane", "center": [0, 0, 1], "normal": [0, 0, 1]}]},
|
||||
)
|
||||
self.assertEqual(snapshot["records"][0]["record_id"], "preview_face")
|
||||
self.assertTrue(snapshot["records"][0]["synthetic"])
|
||||
self.assertFalse(snapshot["records"][0]["executable"])
|
||||
Reference in New Issue
Block a user