更新功能

This commit is contained in:
2026-08-26 14:13:11 +08:00
parent beaa5dd9fe
commit a508e6f07e
47 changed files with 5616 additions and 520 deletions
+62 -1
View File
@@ -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")]
+230 -1
View File
@@ -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(
+68
View File
@@ -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"})
+134
View File
@@ -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()
+1 -1
View File
@@ -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:
+75
View File
@@ -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"])