From 4a2e9d658f051cb756f92cb8fae945c64b8cc7d4 Mon Sep 17 00:00:00 2001 From: Umutcan Edizsalan Date: Fri, 17 Jul 2026 15:48:43 +0300 Subject: [PATCH] release: prepare v1.3.0 --- CHANGELOG.md | 42 ++- README.md | 2 +- backends/base.py | 142 ++++++++ backends/com_backend.py | 165 ++++++++- backends/ezdxf_backend.py | 221 +++++++++++- benchmarks/README.md | 8 +- benchmarks/__init__.py | 1 + benchmarks/adapters/__init__.py | 5 + benchmarks/adapters/autocad_mcp_pro.py | 151 +++++++++ benchmarks/adapters/base.py | 58 ++++ benchmarks/competitors.yaml | 10 + benchmarks/correctness_suite.py | 7 + benchmarks/results/.gitkeep | 1 + benchmarks/run_competitors.py | 156 +++++++++ benchmarks/tasks_v2.py | 34 ++ ...ository-cleanup-readme-benchmark-design.md | 2 +- engineering/__init__.py | 29 ++ engineering/annotation.py | 87 +++++ engineering/delivery.py | 320 ++++++++++++++++++ engineering/plan_spec.py | 6 + engineering/preflight.py | 218 ++++++++++++ engineering/refiner.py | 271 +++++++++++++++ engineering/repairs.py | 81 +++++ pyproject.toml | 6 +- server.py | 214 ++++++++++-- tests/test_benchmark_v2.py | 108 ++++++ tests/test_capabilities.py | 74 ++++ tests/test_com_backend.py | 29 ++ tests/test_delivery.py | 139 ++++++++ tests/test_preflight.py | 166 +++++++++ tests/test_refiner.py | 139 ++++++++ tests/test_server_entrypoint.py | 41 +++ tests/test_sprint4_ezdxf.py | 7 +- tests/test_table_leader.py | 141 ++++++++ version.py | 20 ++ 35 files changed, 3053 insertions(+), 48 deletions(-) create mode 100644 benchmarks/__init__.py create mode 100644 benchmarks/adapters/__init__.py create mode 100644 benchmarks/adapters/autocad_mcp_pro.py create mode 100644 benchmarks/adapters/base.py create mode 100644 benchmarks/competitors.yaml create mode 100644 benchmarks/results/.gitkeep create mode 100644 benchmarks/run_competitors.py create mode 100644 benchmarks/tasks_v2.py create mode 100644 engineering/annotation.py create mode 100644 engineering/delivery.py create mode 100644 engineering/preflight.py create mode 100644 engineering/refiner.py create mode 100644 engineering/repairs.py create mode 100644 tests/test_benchmark_v2.py create mode 100644 tests/test_capabilities.py create mode 100644 tests/test_delivery.py create mode 100644 tests/test_preflight.py create mode 100644 tests/test_refiner.py create mode 100644 tests/test_server_entrypoint.py create mode 100644 tests/test_table_leader.py create mode 100644 version.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 914fc7c..15927db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,47 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -_Next: closed-loop refiner (critique→repair→re-critique with transaction-stack isolation), pre-plan clarification pass, ISO 286 fit-table lookup (H7/g6 → deviations), and a local-VLM visual judge._ +_Next: capability-group schema routing, paper-space/layout production depth, and ISO 286 fit-table lookup._ + +## [1.3.0] — 2026-07-17 + +Closed-loop quality, production annotation, and auditable delivery. **415 tests, +Ruff lint-clean. Tool count: 116 → 122.** + +### Added + +- **`drawing_preflight`** normalizes production requirements, reports missing or + conflicting facts before geometry starts, and emits a deterministic SHA-256 + spec hash that `drawing_plan` can enforce. +- **`drawing_refine`** runs a bounded `critique → repair → re-critique` loop. + Each repair round is isolated in its own transaction and rolls back if score + regresses or hard-error count increases. Construction, duplicate, layer + color/lineweight, untrimmed endpoint, and dimension-overlap repairs are + supported; undefined GD&T datums remain explicitly manual. +- **TABLE and MLEADER semantics** via `entity_create_table` and + `leader_create_mleader`: native ActiveX entities on COM and deterministic, + portable LINE/LWPOLYLINE/MTEXT composites on ezdxf. Composite creation + returns child handles and a logical ID; after DXF reopen, inspection uses the + persisted standard child entities rather than a native TABLE/MLEADER object. +- **`system_capabilities`** and shared typed backend capability maps distinguish + native, rendered, composite, snapshot, shared, and unsupported features. +- **`drawing_deliver`** creates DXF/PDF/PNG bundles, runs validator + critique, + re-opens the canonical DXF for entity/type/layer/bounds parity, hashes every + artifact, and writes `manifest.json` plus `validation.json`. Failed gates keep + artifacts for diagnosis but never report delivery success. +- **Benchmark v2**: ten fixed vendor-neutral tasks, an adapter interface, an + ezdxf/COM reference adapter, cooperative per-task timeout reporting, + fixed-matrix coverage-aware scoring, machine-readable statuses, + runtime/environment/capability metadata, and artifact SHA-256. + +### Changed + +- ezdxf transaction snapshots are isolated from user-facing undo history, so a + refiner rollback cannot consume an unrelated undo entry. +- Package metadata is the canonical version source used by `system_about` and + delivery manifests. +- `benchmarks/correctness_suite.py` now shows usage for argless/`--help` calls + instead of raising a traceback. ### Fixed - **Install from source** — `pip install -e ".[full]"` failed at diff --git a/README.md b/README.md index 24a2815..24423f6 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ query exact geometry, apply engineering standards, refine quality issues inside transactions, and deliver hashed artifacts with validation evidence. > **v1.3 release snapshot:** 122 tools · 6 resources · 5 prompt templates · -> 406 collected tests. Runtime discovery through `system_about` is authoritative. +> 415 collected tests. Runtime discovery through `system_about` is authoritative. ## Start in 60 seconds diff --git a/backends/base.py b/backends/base.py index bf4e1e2..4dbfa33 100644 --- a/backends/base.py +++ b/backends/base.py @@ -71,6 +71,32 @@ class DrawingInfo: backend: str = "" +@dataclass(frozen=True) +class FeatureCapability: + supported: bool + mode: str | None = None + reason: str | None = None + + def to_dict(self) -> dict: + return { + "supported": self.supported, + "mode": self.mode, + "reason": self.reason, + } + + +@dataclass(frozen=True) +class CapabilityMap: + backend: str + features: dict[str, FeatureCapability] + + def to_dict(self) -> dict: + return { + "backend": self.backend, + "features": {name: value.to_dict() for name, value in self.features.items()}, + } + + # --------------------------------------------------------------------------- # Shared helpers # --------------------------------------------------------------------------- @@ -126,6 +152,9 @@ class AutoCADBackend(ABC): @abstractmethod async def disconnect(self) -> None: ... + @abstractmethod + def capabilities(self) -> CapabilityMap: ... + # ── drawing management ─────────────────────────────────────────────────── @abstractmethod async def drawing_info(self) -> DrawingInfo: ... @@ -205,6 +234,31 @@ class AutoCADBackend(ABC): layer: str | None = None, color: int | None = None, ) -> EntityInfo: ... + @abstractmethod + async def entity_create_table( + self, + x: float, + y: float, + rows: list[list[str]], + headers: list[str] | None = None, + column_widths: list[float] | None = None, + row_height: float = 7.0, + text_height: float = 2.5, + title: str | None = None, + layer: str = "TEXT", + ) -> EntityInfo: ... + + @abstractmethod + async def leader_create_mleader( + self, + points: list[list[float]], + text: str, + text_height: float = 2.5, + landing_gap: float = 1.0, + arrow_size: float = 2.5, + layer: str = "DIM", + ) -> EntityInfo: ... + @abstractmethod async def entity_create_hatch( self, pattern: str, boundary_points: list[list[float]], @@ -594,6 +648,46 @@ class AutoCADBackend(ABC): # has no cross-backend equivalent among the standard entity_create_* tools. _plan_spec: PlanSpec | None = None + _preflight_result: Any = None + + def _reset_document_state(self) -> None: + """Clear metadata that is valid only for the current drawing.""" + self._plan_spec = None + self._preflight_result = None + self._gdt_datums_defined = set() + self._gdt_datums_referenced = set() + + async def _ensure_document_state(self) -> None: + """Allow live backends to detect an out-of-band document switch.""" + return None + + async def drawing_preflight( + self, + intent: str, + requirements: dict | None = None, + sheet_size: SheetSize = "A3", + scale: float = 1.0, + layer_set_id: LayerSetId = "mech", + view_count: int = 1, + dim_style: DimStyle = "chain", + allow_assumptions: bool = False, + ): + from engineering.preflight import preflight_drawing + + await self._ensure_document_state() + + result = preflight_drawing( + intent, + requirements, + sheet_size, + scale, + layer_set_id, + view_count, + dim_style, + allow_assumptions=allow_assumptions, + ) + self._preflight_result = result + return result async def drawing_plan( self, intent: str, @@ -603,6 +697,8 @@ class AutoCADBackend(ABC): view_count: int = 1, dim_style: DimStyle = "chain", notes: list[str] | None = None, + requirements: dict | None = None, + spec_hash: str | None = None, ) -> PlanSpec: """Commit a drawing intent before any geometry is created. Returns a PlanSpec and stores it on the backend; read it back with @@ -610,6 +706,47 @@ class AutoCADBackend(ABC): replays this plan was untrue — critique runs geometric checks, not a plan diff.)""" from engineering.plan_spec import PlanSpec + + await self._ensure_document_state() + preflight_status = "skipped" + normalized_plan: dict[str, Any] | None = None + if spec_hash is not None: + from engineering.preflight import preflight_drawing + + preflight = getattr(self, "_preflight_result", None) + if preflight is None or preflight.spec_hash != spec_hash: + raise RuntimeError("drawing_plan: spec_hash does not match the latest preflight.") + if not preflight.ready: + raise RuntimeError( + "drawing_plan: preflight is not ready; resolve questions/conflicts first." + ) + candidate = preflight_drawing( + intent, + requirements + if requirements is not None + else preflight.normalized_spec["requirements"], + sheet_size, + scale, + layer_set_id, + view_count, + dim_style, + ) + if candidate.spec_hash != spec_hash: + raise RuntimeError( + "drawing_plan: supplied fields do not match the hashed preflight spec." + ) + normalized_plan = preflight.normalized_spec + preflight_status = "ready" + + if normalized_plan is not None: + intent = normalized_plan["intent"] + sheet_size = normalized_plan["sheet_size"] + scale = normalized_plan["scale"] + layer_set_id = normalized_plan["layer_set_id"] + view_count = normalized_plan["view_count"] + dim_style = normalized_plan["dim_style"] + requirements = normalized_plan["requirements"] + plan = PlanSpec( intent=str(intent), sheet_size=sheet_size, @@ -618,6 +755,9 @@ class AutoCADBackend(ABC): view_count=int(view_count), dim_style=dim_style, notes=list(notes) if notes else [], + requirements=dict(requirements or {}), + spec_hash=spec_hash, + preflight_status=preflight_status, ) self._plan_spec = plan return plan @@ -629,6 +769,8 @@ class AutoCADBackend(ABC): `drawing_finalize`. `focus=None` runs all checks. (N8: this does NOT replay the committed PlanSpec — it inspects the live drawing geometry.)""" from engineering.critique import run_critique + + await self._ensure_document_state() return await run_critique(self, focus) def get_plan_spec(self) -> dict | None: diff --git a/backends/com_backend.py b/backends/com_backend.py index 3a1e2f3..b5ec8f4 100644 --- a/backends/com_backend.py +++ b/backends/com_backend.py @@ -21,8 +21,10 @@ import config from .base import ( AutoCADBackend, BlockInfo, + CapabilityMap, DrawingInfo, EntityInfo, + FeatureCapability, LayerInfo, deg2rad, normalize_lineweight, @@ -431,6 +433,7 @@ class ComBackend(AutoCADBackend): self._executor: ThreadPoolExecutor | None = None self._connected = False self._transaction_active = False + self._document_scope_key: tuple[str, str] | None = None @property def name(self) -> str: @@ -440,6 +443,26 @@ class ComBackend(AutoCADBackend): def is_connected(self) -> bool: return self._connected + def capabilities(self) -> CapabilityMap: + return CapabilityMap( + backend="com", + features={ + "drawing_2d": FeatureCapability(True, "native"), + "dxf": FeatureCapability(True, "native"), + "pdf": FeatureCapability(True, "native"), + "png": FeatureCapability(True, "native"), + "transactions": FeatureCapability(True, "undo_mark"), + "table": FeatureCapability(True, "native"), + "mleader": FeatureCapability(True, "native"), + "preflight": FeatureCapability(True, "shared"), + "refiner": FeatureCapability(True, "shared"), + "delivery": FeatureCapability(True, "shared"), + "paper_space": FeatureCapability(False, reason="planned_1.4"), + "solid_3d": FeatureCapability(False, reason="planned_1.5"), + "lisp": FeatureCapability(True, "sanitized"), + }, + ) + async def connect(self) -> None: if not _COM_IMPORTS_OK: raise RuntimeError( @@ -460,8 +483,28 @@ class ComBackend(AutoCADBackend): pass self._executor.shutdown(wait=False) self._executor = None + self._document_scope_key = None + self._transaction_active = False + self._reset_document_state() self._connected = False + async def _ensure_document_state(self) -> None: + """Reset drawing-scoped metadata when AutoCAD's active document changes.""" + + def _sync(): + app = _acad_app() + if app.Documents.Count == 0: + key = None + else: + doc = app.ActiveDocument + key = (str(doc.Name), str(doc.FullName)) + if key != self._document_scope_key: + self._document_scope_key = key + self._transaction_active = False + self._reset_document_state() + + await self._run(_sync) + def _sync_test_connection(self): """Verify AutoCAD is reachable (runs in COM thread).""" app = _acad_app() @@ -572,14 +615,18 @@ class ComBackend(AutoCADBackend): else: doc = app.Documents.Add() return {"ok": True, "name": doc.Name} - return await self._run(_sync) + result = await self._run(_sync) + await self._ensure_document_state() + return result async def drawing_open(self, path: str) -> dict: def _sync(): app = _acad_app() doc = app.Documents.Open(path) return {"ok": True, "name": doc.Name, "path": doc.FullName} - return await self._run(_sync) + result = await self._run(_sync) + await self._ensure_document_state() + return result async def drawing_save(self, path: str | None = None) -> dict: def _sync(): @@ -633,7 +680,9 @@ class ComBackend(AutoCADBackend): doc = _acad_doc() doc.Close(save) return {"ok": True} - return await self._run(_sync) + result = await self._run(_sync) + await self._ensure_document_state() + return result async def drawing_undo(self) -> dict: def _sync(): @@ -729,6 +778,113 @@ class ComBackend(AutoCADBackend): return _entity_info(mt) return await self._run(_sync) + async def entity_create_table( + self, + x, + y, + rows, + headers=None, + column_widths=None, + row_height=7.0, + text_height=2.5, + title=None, + layer="TEXT", + ) -> EntityInfo: + from engineering.annotation import prepare_table_layout + + layout = prepare_table_layout( + rows, headers, column_widths, row_height, text_height, title + ) + + def _sync(): + mspace = _msp() + table = mspace.AddTable( + _apoint(x, y), + layout.row_count, + layout.column_count, + layout.row_height, + layout.column_widths[0], + ) + for column, width in enumerate(layout.column_widths): + table.SetColumnWidth(column, width) + for row_index, row in enumerate(layout.cells): + table.SetRowHeight(row_index, layout.row_height) + for column_index, value in enumerate(row): + table.SetText(row_index, column_index, value) + try: + table.TextHeight = layout.text_height + except Exception as exc: + log.debug("setting table TextHeight failed: %s", exc) + _apply_entity_attrs(table, layer, None, None) + _regen() + info = _entity_info(table) + info.type = "TABLE" + info.layer = layer + info.properties.update( + { + "representation": "native", + "logical_group_id": f"table:{uuid.uuid4().hex}", + "child_handles": [info.handle], + "rows": layout.row_count, + "columns": layout.column_count, + "bounds": { + "min": [float(x), float(y) - layout.height], + "max": [float(x) + layout.width, float(y)], + }, + } + ) + return info + + return await self._run(_sync) + + async def leader_create_mleader( + self, + points, + text, + text_height=2.5, + landing_gap=1.0, + arrow_size=2.5, + layer="DIM", + ) -> EntityInfo: + from engineering.annotation import validate_mleader + + normalized = validate_mleader(points, text) + if text_height <= 0 or landing_gap < 0 or arrow_size <= 0: + raise RuntimeError( + "leader_create_mleader: text_height/arrow_size must be positive and landing_gap non-negative" + ) + + def _sync(): + flat = [] + for px, py in normalized: + flat.extend([px, py, 0.0]) + leader = _msp().AddMLeader(_av(flat), 0) + leader.TextString = str(text) + leader.TextHeight = float(text_height) + leader.LandingGap = float(landing_gap) + leader.ArrowheadSize = float(arrow_size) + _apply_entity_attrs(leader, layer, None, None) + _regen() + info = _entity_info(leader) + info.type = "MLEADER" + info.layer = layer + info.properties.update( + { + "representation": "native", + "logical_group_id": f"mleader:{uuid.uuid4().hex}", + "child_handles": [info.handle], + "points": [list(point) for point in normalized], + "text": str(text), + "bounds": { + "min": [min(p[0] for p in normalized), min(p[1] for p in normalized)], + "max": [max(p[0] for p in normalized), max(p[1] for p in normalized)], + }, + } + ) + return info + + return await self._run(_sync) + async def entity_create_hatch( self, pattern, boundary_points, scale=1.0, angle=0.0, layer=None, color=None, @@ -1649,6 +1805,9 @@ class ComBackend(AutoCADBackend): # ── transactions ────────────────────────────────────────────────────────── async def transaction_begin(self) -> dict: + if self._transaction_active: + return {"ok": False, "error": "A transaction is already active"} + def _sync(): doc = _acad_doc() doc.StartUndoMark() diff --git a/backends/ezdxf_backend.py b/backends/ezdxf_backend.py index 7a864b8..8fc364b 100644 --- a/backends/ezdxf_backend.py +++ b/backends/ezdxf_backend.py @@ -13,6 +13,8 @@ import logging import math import os import tempfile +import uuid +from importlib.util import find_spec from pathlib import Path from typing import Any @@ -21,8 +23,10 @@ import config from .base import ( AutoCADBackend, BlockInfo, + CapabilityMap, DrawingInfo, EntityInfo, + FeatureCapability, LayerInfo, normalize_lineweight, shoelace_area, @@ -253,7 +257,8 @@ class EzdxfBackend(AutoCADBackend): self._doc_path: str | None = None self._dirty: bool = False self._current_layer: str = "0" - self._undo_stack: list[Path] = [] # temp-file paths to DXF snapshots + self._undo_stack: list[Path] = [] # user-facing undo history + self._transaction_stack: list[Path] = [] # isolated transaction snapshots self._connected = False self._lock = asyncio.Lock() @@ -265,21 +270,49 @@ class EzdxfBackend(AutoCADBackend): def is_connected(self) -> bool: return self._connected + def capabilities(self) -> CapabilityMap: + render_available = find_spec("matplotlib") is not None + render_capability = ( + FeatureCapability(True, "rendered") + if render_available + else FeatureCapability(False, reason="optional_dependency_missing:matplotlib") + ) + return CapabilityMap( + backend="ezdxf", + features={ + "drawing_2d": FeatureCapability(True, "native"), + "dxf": FeatureCapability(True, "native"), + "pdf": render_capability, + "png": render_capability, + "transactions": FeatureCapability(True, "snapshot"), + "table": FeatureCapability(True, "composite"), + "mleader": FeatureCapability(True, "composite"), + "preflight": FeatureCapability(True, "shared"), + "refiner": FeatureCapability(True, "shared"), + "delivery": FeatureCapability(True, "shared"), + "paper_space": FeatureCapability(False, reason="planned_1.4"), + "solid_3d": FeatureCapability(False, reason="planned_1.5"), + "lisp": FeatureCapability(False, reason="live_com_only"), + }, + ) + async def connect(self) -> None: self._connected = True log.info("ezdxf backend ready") async def disconnect(self) -> None: self._cleanup_undo_stack() + self._reset_document_state() self._connected = False def _cleanup_undo_stack(self): - while self._undo_stack: - p = self._undo_stack.pop() - try: - p.unlink() - except OSError: - pass + for stack in (self._undo_stack, self._transaction_stack): + while stack: + p = stack.pop() + try: + p.unlink() + except OSError: + pass def _require_doc(self): if self._doc is None: @@ -347,6 +380,7 @@ class EzdxfBackend(AutoCADBackend): self._doc_path = None self._dirty = False self._current_layer = "0" + self._reset_document_state() return {"ok": True, "name": "untitled.dxf"} return await self._async(_sync) @@ -371,6 +405,7 @@ class EzdxfBackend(AutoCADBackend): except Exception as exc: log.debug("reading $CLAYER from header: %s", exc) self._current_layer = "0" + self._reset_document_state() return {"ok": True, "name": Path(path).name, "path": path} return await self._async(_sync) @@ -511,6 +546,7 @@ class EzdxfBackend(AutoCADBackend): self._doc = None self._doc_path = None self._dirty = False + self._reset_document_state() return {"ok": True} return await self._async(_sync) @@ -645,6 +681,160 @@ class EzdxfBackend(AutoCADBackend): return _entity_info_dxf(ent) return await self._async(_sync) + async def entity_create_table( + self, + x, + y, + rows, + headers=None, + column_widths=None, + row_height=7.0, + text_height=2.5, + title=None, + layer="TEXT", + ) -> EntityInfo: + from engineering.annotation import prepare_table_layout + + layout = prepare_table_layout( + rows, headers, column_widths, row_height, text_height, title + ) + + def _sync(): + msp = self._msp() + children = [] + x0, y0 = float(x), float(y) + x_positions = [x0] + for width in layout.column_widths: + x_positions.append(x_positions[-1] + width) + + for row_index in range(layout.row_count + 1): + yy = y0 - row_index * layout.row_height + line = msp.add_line((x0, yy), (x0 + layout.width, yy)) + self._apply_attrs(line, layer, None) + children.append(line) + for xx in x_positions: + line = msp.add_line((xx, y0), (xx, y0 - layout.height)) + self._apply_attrs(line, layer, None) + children.append(line) + + padding = min(1.0, layout.row_height * 0.15) + for row_index, row in enumerate(layout.cells): + for column_index, value in enumerate(row): + if not value: + continue + text_entity = msp.add_mtext( + value, + dxfattribs={ + "char_height": layout.text_height, + "width": max(1.0, layout.column_widths[column_index] - 2 * padding), + }, + ) + text_entity.dxf.insert = ( + x_positions[column_index] + padding, + y0 - row_index * layout.row_height - padding, + 0.0, + ) + self._apply_attrs(text_entity, layer, None) + children.append(text_entity) + + self._mark_dirty() + child_handles = [str(entity.dxf.handle) for entity in children] + group_id = f"table:{uuid.uuid4().hex}" + return EntityInfo( + handle=child_handles[0], + type="TABLE", + layer=layer, + color=256, + linetype="ByLayer", + visible=True, + properties={ + "representation": "composite", + "logical_group_id": group_id, + "child_handles": child_handles, + "rows": layout.row_count, + "columns": layout.column_count, + "bounds": { + "min": [x0, y0 - layout.height], + "max": [x0 + layout.width, y0], + }, + }, + ) + + return await self._async(_sync) + + async def leader_create_mleader( + self, + points, + text, + text_height=2.5, + landing_gap=1.0, + arrow_size=2.5, + layer="DIM", + ) -> EntityInfo: + from engineering.annotation import validate_mleader + + normalized = validate_mleader(points, text) + if text_height <= 0 or landing_gap < 0 or arrow_size <= 0: + raise RuntimeError( + "leader_create_mleader: text_height/arrow_size must be positive and landing_gap non-negative" + ) + + def _sync(): + msp = self._msp() + path = msp.add_lwpolyline(normalized) + self._apply_attrs(path, layer, None) + + (x0, y0), (x1, y1) = normalized[0], normalized[1] + dx, dy = x1 - x0, y1 - y0 + length = math.hypot(dx, dy) + if length <= 1e-12: + raise RuntimeError("leader_create_mleader: first two points must be distinct") + ux, uy = dx / length, dy / length + px, py = -uy, ux + size = float(arrow_size) + arrow = msp.add_lwpolyline( + [ + (x0, y0), + (x0 + ux * size + px * size * 0.35, y0 + uy * size + py * size * 0.35), + (x0 + ux * size - px * size * 0.35, y0 + uy * size - py * size * 0.35), + ], + close=True, + ) + self._apply_attrs(arrow, layer, None) + + tx, ty = normalized[-1] + label = msp.add_mtext( + str(text), + dxfattribs={"char_height": float(text_height), "width": 100.0}, + ) + label.dxf.insert = (tx + float(landing_gap), ty, 0.0) + self._apply_attrs(label, layer, None) + self._mark_dirty() + + children = [path, arrow, label] + child_handles = [str(entity.dxf.handle) for entity in children] + return EntityInfo( + handle=child_handles[0], + type="MLEADER", + layer=layer, + color=256, + linetype="ByLayer", + visible=True, + properties={ + "representation": "composite", + "logical_group_id": f"mleader:{uuid.uuid4().hex}", + "child_handles": child_handles, + "points": [list(point) for point in normalized], + "text": str(text), + "bounds": { + "min": [min(point[0] for point in normalized), min(point[1] for point in normalized)], + "max": [max(point[0] for point in normalized), max(point[1] for point in normalized)], + }, + }, + ) + + return await self._async(_sync) + async def entity_create_hatch( self, pattern, boundary_points, scale=1.0, angle=0.0, layer=None, color=None, @@ -1632,10 +1822,10 @@ class EzdxfBackend(AutoCADBackend): except OSError: pass raise - self._undo_stack.append(Path(tmp_path)) + self._transaction_stack.append(Path(tmp_path)) max_stack = config.settings.max_undo_stack - while len(self._undo_stack) > max_stack: - old = self._undo_stack.pop(0) + while len(self._transaction_stack) > max_stack: + old = self._transaction_stack.pop(0) try: old.unlink() except OSError: @@ -1647,9 +1837,9 @@ class EzdxfBackend(AutoCADBackend): # NEW-undo-1: guard the _undo_stack read+pop with self._lock (via _async) # instead of touching it outside any lock. def _sync(): - if not self._undo_stack: + if not self._transaction_stack: return {"ok": False, "error": "No active transaction"} - p = self._undo_stack.pop() + p = self._transaction_stack.pop() try: p.unlink() except OSError: @@ -1661,11 +1851,12 @@ class EzdxfBackend(AutoCADBackend): # NEW-undo-1: read+mutate _undo_stack under self._lock (via _async) so the # empty-check and the pop/readfile are serialized with other sync work. def _sync(): - if not self._undo_stack: + if not self._transaction_stack: return {"ok": False, "error": "No active transaction to rollback"} - p = self._undo_stack.pop() + p = self._transaction_stack.pop() try: self._doc = ezdxf.readfile(str(p)) + self._current_layer = str(self._doc.header.get("$CLAYER", "0")) self._dirty = True finally: try: @@ -1685,7 +1876,7 @@ class EzdxfBackend(AutoCADBackend): "has_document": has_doc, "document_path": self._doc_path, "unsaved_changes": self._dirty, - "transaction_depth": len(self._undo_stack), + "transaction_depth": len(self._transaction_stack), "capabilities": [ "file_read_write", "dxf_export", "pdf_export", "entity_creation", "layer_management", "blocks", diff --git a/benchmarks/README.md b/benchmarks/README.md index c48f576..eee269f 100644 --- a/benchmarks/README.md +++ b/benchmarks/README.md @@ -29,8 +29,12 @@ dates, evidence grades, project URLs, and the boundary are stored with the data. The v2 runner separates runtime evidence from the source-review rubric. Every adapter receives the same ten tasks and produces the same closed result enum: `pass`, `partial`, `unsupported`, `fail`, `timeout`, or `not_run`. A timeout is -isolated to its task. Reports include commit SHA, Python/platform details, -backend capability claims, durations, and hashes for returned artifacts. +reported per task. Execution is in-process, so adapters that delegate blocking +work must provide cancellation-safe task implementations or be wrapped by an +external process supervisor. Reports include commit SHA, Python/platform +details, backend capability claims, durations, coverage, and hashes for returned +artifacts. Unsupported tasks remain in the fixed-matrix denominator with score +zero, preventing partial implementations from receiving an inflated score. ```bash python -m benchmarks.run_competitors --list diff --git a/benchmarks/__init__.py b/benchmarks/__init__.py new file mode 100644 index 0000000..c069ef2 --- /dev/null +++ b/benchmarks/__init__.py @@ -0,0 +1 @@ +"""Benchmark suites for AutoCAD MCP Pro.""" diff --git a/benchmarks/adapters/__init__.py b/benchmarks/adapters/__init__.py new file mode 100644 index 0000000..d2454dc --- /dev/null +++ b/benchmarks/adapters/__init__.py @@ -0,0 +1,5 @@ +"""Benchmark adapters.""" + +from .base import BenchmarkAdapter, TaskResult + +__all__ = ["BenchmarkAdapter", "TaskResult"] diff --git a/benchmarks/adapters/autocad_mcp_pro.py b/benchmarks/adapters/autocad_mcp_pro.py new file mode 100644 index 0000000..149a7ed --- /dev/null +++ b/benchmarks/adapters/autocad_mcp_pro.py @@ -0,0 +1,151 @@ +"""Reference v2 adapter for this repository.""" + +from __future__ import annotations + +from pathlib import Path + +from benchmarks.adapters.base import BenchmarkAdapter, TaskResult +from benchmarks.tasks_v2 import TaskSpec +from engineering.delivery import deliver_drawing +from engineering.layers import ensure_engineering_layers, ensure_standard_linetypes +from engineering.refiner import refine_drawing + + +class AutoCADMCPProAdapter(BenchmarkAdapter): + name = "autocad-mcp-pro" + + def __init__(self, *, backend: str = "ezdxf"): + self.backend_name = backend + self.backend = None + self.artifact_dir = Path() + + async def setup(self, artifact_dir: Path) -> None: + if self.backend_name == "ezdxf": + from backends.ezdxf_backend import EzdxfBackend + + self.backend = EzdxfBackend() + elif self.backend_name == "com": + from backends.com_backend import ComBackend + + self.backend = ComBackend() + else: + raise ValueError(f"Unknown backend: {self.backend_name}") + self.artifact_dir = artifact_dir + await self.backend.connect() + + async def cleanup(self) -> None: + if self.backend is not None: + await self.backend.disconnect() + + def metadata(self) -> dict: + return { + "backend": self.backend_name, + "capabilities": self.backend.capabilities().to_dict() if self.backend else None, + } + + async def _reset(self) -> None: + await self.backend.drawing_new() + + async def run_task(self, task: TaskSpec) -> TaskResult: + await self._reset() + handler = getattr(self, f"_task_{task.task_id}", None) + if handler is None: + return TaskResult(task.task_id, "unsupported", 0.0, "No adapter implementation") + passed, metrics, artifacts = await handler() + return TaskResult( + task.task_id, + "pass" if passed else "fail", + 100.0 if passed else 0.0, + metrics=metrics, + artifacts=artifacts, + ) + + async def _task_core_geometry(self): + line = await self.backend.entity_create_line(0, 0, 3, 4) + circle = await self.backend.entity_create_circle(10, 10, 2) + info = await self.backend.entity_get(line.handle) + passed = abs(float(info.properties["length"]) - 5.0) < 1e-6 + return passed and bool(circle.handle), {"line_length": info.properties["length"]}, [] + + async def _task_modify_query(self): + line = await self.backend.entity_create_line(0, 0, 10, 0) + await self.backend.entity_move(line.handle, 5, 2) + moved = await self.backend.entity_get(line.handle) + return moved.properties["start"][:2] == [5.0, 2.0], {}, [] + + async def _task_layers_linetypes(self): + await ensure_standard_linetypes(self.backend) + await ensure_engineering_layers(self.backend) + layers = {item.name for item in await self.backend.layer_list()} + linetypes = {item.upper() for item in await self.backend.linetype_list()} + return "GEOMETRY" in layers and "CENTER" in linetypes, {}, [] + + async def _task_dimensions(self): + await ensure_engineering_layers(self.backend) + dimension = await self.backend.dimension_linear(0, 0, 50, 0, 25, 10, layer="DIM") + return "DIM" in dimension.type.upper(), {"entity_type": dimension.type}, [] + + async def _task_table_mleader(self): + await ensure_engineering_layers(self.backend) + table = await self.backend.entity_create_table( + 0, 20, [["A", "1"]], headers=["ITEM", "QTY"], layer="TEXT" + ) + leader = await self.backend.leader_create_mleader( + [[0, 0], [10, 10]], "NOTE", layer="DIM" + ) + representations = [table.properties.get("representation"), leader.properties.get("representation")] + return all(representations), {"representations": representations}, [] + + async def _task_transactions(self): + await self.backend.transaction_begin() + await self.backend.entity_create_circle(0, 0, 1) + await self.backend.transaction_rollback() + entities = await self.backend.entity_list(limit=100) + return len(entities) == 0, {"entity_count": len(entities)}, [] + + async def _task_preflight(self): + result = await self.backend.drawing_preflight( + "Benchmark plate", + { + "units": "mm", + "part_type": "plate", + "dimensions": {"width": 50, "height": 25}, + "tolerance_policy": "ISO 2768-m", + }, + ) + return result.ready and result.spec_hash.startswith("sha256:"), {}, [] + + async def _task_quality_refiner(self): + await ensure_engineering_layers(self.backend) + await self.backend.entity_create_line(0, 0, 10, 0, layer="GEOMETRY") + await self.backend.entity_create_line(0, 0, 10, 0, layer="GEOMETRY") + result = await refine_drawing(self.backend, focus=["duplicate_entities"]) + return result.final_score > result.initial_score, result.to_dict(), [] + + async def _task_dxf_roundtrip(self): + await self.backend.entity_create_line(0, 0, 10, 10) + path = self.artifact_dir / "roundtrip.dxf" + await self.backend.drawing_export_dxf(str(path)) + from backends.ezdxf_backend import EzdxfBackend + + reopened = EzdxfBackend() + await reopened.connect() + try: + await reopened.drawing_open(str(path)) + count = (await reopened.drawing_info()).entity_count + finally: + await reopened.disconnect() + return count == 1, {"entity_count": count}, [str(path)] + + async def _task_auditable_delivery(self): + await ensure_engineering_layers(self.backend) + await self.backend.entity_create_line(0, 0, 10, 0, layer="GEOMETRY") + destination = self.artifact_dir / "delivery" + result = await deliver_drawing( + self.backend, + destination, + formats=["dxf"], + min_score=0, + strict_critique=False, + ) + return result.status == "success", {"score": result.score}, [result.manifest_path] diff --git a/benchmarks/adapters/base.py b/benchmarks/adapters/base.py new file mode 100644 index 0000000..1da930d --- /dev/null +++ b/benchmarks/adapters/base.py @@ -0,0 +1,58 @@ +"""Common, vendor-neutral benchmark adapter contract.""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from pathlib import Path +from typing import TYPE_CHECKING, Any, Literal + +if TYPE_CHECKING: + from benchmarks.tasks_v2 import TaskSpec + +TaskStatus = Literal["pass", "partial", "unsupported", "fail", "timeout", "not_run"] + + +@dataclass +class TaskResult: + task_id: str + status: TaskStatus + score: float + message: str = "" + duration_ms: float = 0.0 + artifacts: list[str | dict[str, Any]] = field(default_factory=list) + metrics: dict = field(default_factory=dict) + stdout_summary: str = "" + stderr_summary: str = "" + + def to_dict(self) -> dict: + return { + "task_id": self.task_id, + "status": self.status, + "score": float(self.score), + "message": self.message, + "duration_ms": float(self.duration_ms), + "artifacts": list(self.artifacts), + "metrics": dict(self.metrics), + "stdout_summary": self.stdout_summary, + "stderr_summary": self.stderr_summary, + } + + +class BenchmarkAdapter(ABC): + """Minimum interface every competing MCP adapter must implement.""" + + name: str + + def metadata(self) -> dict: + """Runtime/backend claims captured alongside every benchmark report.""" + return {} + + @abstractmethod + async def setup(self, artifact_dir: Path) -> None: ... + + @abstractmethod + async def run_task(self, task: TaskSpec) -> TaskResult: ... + + @abstractmethod + async def cleanup(self) -> None: ... diff --git a/benchmarks/competitors.yaml b/benchmarks/competitors.yaml new file mode 100644 index 0000000..8c564ad --- /dev/null +++ b/benchmarks/competitors.yaml @@ -0,0 +1,10 @@ +{ + "schema_version": "2.0", + "competitors": [ + { + "id": "autocad-mcp-pro", + "adapter": "benchmarks.adapters.autocad_mcp_pro:AutoCADMCPProAdapter", + "backends": ["ezdxf", "com"] + } + ] +} diff --git a/benchmarks/correctness_suite.py b/benchmarks/correctness_suite.py index 7238587..3339b02 100644 --- a/benchmarks/correctness_suite.py +++ b/benchmarks/correctness_suite.py @@ -224,11 +224,18 @@ CHECKS = { def main(): + if len(sys.argv) == 1 or sys.argv[1] in {"-h", "--help"}: + print("usage: correctness_suite.py | --list") + print("Run one deterministic correctness check, or list available checks.") + return if len(sys.argv) == 2 and sys.argv[1] == "--list": for k, (_fn, cat) in CHECKS.items(): print(f"{k}\t{cat}") return name = sys.argv[1] + if name not in CHECKS: + print(f"Unknown check: {name}", file=sys.stderr) + raise SystemExit(2) fn, _cat = CHECKS[name] ok = asyncio.run(fn()) print("PASS" if ok else "FAIL") diff --git a/benchmarks/results/.gitkeep b/benchmarks/results/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/benchmarks/results/.gitkeep @@ -0,0 +1 @@ + diff --git a/benchmarks/run_competitors.py b/benchmarks/run_competitors.py new file mode 100644 index 0000000..5450170 --- /dev/null +++ b/benchmarks/run_competitors.py @@ -0,0 +1,156 @@ +"""Command line runner for the fixed-task benchmark v2 adapter contract.""" + +from __future__ import annotations + +import argparse +import asyncio +import hashlib +import json +import platform +import subprocess +import sys +import time +from datetime import UTC, datetime +from pathlib import Path + +from benchmarks.adapters.base import BenchmarkAdapter, TaskResult +from benchmarks.tasks_v2 import TASKS_V2, TaskSpec, task_by_id + + +def _git_sha() -> str | None: + try: + result = subprocess.run( + ["git", "rev-parse", "HEAD"], + check=True, + capture_output=True, + text=True, + timeout=2, + ) + return result.stdout.strip() or None + except (OSError, subprocess.SubprocessError): + return None + + +def _hash_file(path: Path) -> dict: + digest = hashlib.sha256() + with path.open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(chunk) + return { + "path": str(path), + "filename": path.name, + "size_bytes": path.stat().st_size, + "sha256": digest.hexdigest(), + } + + +def _normalize_artifacts(result: TaskResult) -> None: + normalized: list[dict] = [] + for artifact in result.artifacts: + if isinstance(artifact, dict): + normalized.append(dict(artifact)) + continue + path = Path(artifact).resolve() + if path.is_file(): + normalized.append(_hash_file(path)) + else: + normalized.append({"path": str(path), "missing": True}) + result.artifacts = normalized + + +async def run_tasks( + adapter: BenchmarkAdapter, + tasks: list[TaskSpec] | tuple[TaskSpec, ...], + artifact_dir: str | Path, + *, + timeout: float = 30.0, +) -> dict: + destination = Path(artifact_dir).resolve() + destination.mkdir(parents=True, exist_ok=True) + results: list[TaskResult] = [] + await adapter.setup(destination) + try: + for task in tasks: + started = time.perf_counter() + try: + result = await asyncio.wait_for(adapter.run_task(task), timeout=timeout) + except TimeoutError: + result = TaskResult(task.task_id, "timeout", 0.0, f"Exceeded {timeout}s") + except Exception as exc: + result = TaskResult(task.task_id, "fail", 0.0, str(exc), stderr_summary=str(exc)) + result.duration_ms = round((time.perf_counter() - started) * 1000, 2) + _normalize_artifacts(result) + results.append(result) + finally: + await adapter.cleanup() + + attempted = len(results) + passed = sum(item.status == "pass" for item in results) + supported = [item for item in results if item.status != "unsupported"] + weight_by_id = {task.task_id: task.weight for task in tasks} + total_weight = sum(weight_by_id.values()) + supported_weight = sum(weight_by_id[item.task_id] for item in supported) + weighted_score = sum( + weight_by_id[item.task_id] * item.score + for item in results + if item.status != "unsupported" + ) + return { + "schema_version": "2.0", + "adapter": adapter.name, + "adapter_metadata": adapter.metadata(), + "git_sha": _git_sha(), + "generated_at": datetime.now(UTC).isoformat(), + "environment": { + "python": sys.version.split()[0], + "implementation": platform.python_implementation(), + "platform": platform.platform(), + "machine": platform.machine(), + }, + "timeout_seconds": timeout, + "summary": { + "attempted": attempted, + "passed": passed, + "supported": len(supported), + "unsupported": sum(item.status == "unsupported" for item in results), + "coverage_percent": ( + round(supported_weight / total_weight * 100, 2) if total_weight else 0.0 + ), + "score": round(weighted_score / total_weight, 2) if total_weight else 0.0, + }, + "results": [item.to_dict() for item in results], + } + + +def _parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--list", action="store_true", help="List the fixed benchmark tasks") + parser.add_argument("--server", default="autocad-mcp-pro", choices=["autocad-mcp-pro"]) + parser.add_argument("--backend", default="ezdxf", choices=["ezdxf", "com"]) + parser.add_argument("--task", action="append", help="Run only this task id (repeatable)") + parser.add_argument("--artifact-dir", default="benchmarks/results/latest") + parser.add_argument("--timeout", type=float, default=30.0) + parser.add_argument("--json", action="store_true", help="Print machine-readable JSON") + return parser + + +def main() -> None: + args = _parser().parse_args() + if args.list: + for task in TASKS_V2: + print(f"{task.task_id}\t{task.category}\t{task.description}") + return + + from benchmarks.adapters.autocad_mcp_pro import AutoCADMCPProAdapter + + tasks = [task_by_id(task_id) for task_id in args.task] if args.task else list(TASKS_V2) + adapter = AutoCADMCPProAdapter(backend=args.backend) + report = asyncio.run( + run_tasks(adapter, tasks, args.artifact_dir, timeout=args.timeout) + ) + rendered = json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True) + print(rendered) + + +if __name__ == "__main__": + main() diff --git a/benchmarks/tasks_v2.py b/benchmarks/tasks_v2.py new file mode 100644 index 0000000..5a7c044 --- /dev/null +++ b/benchmarks/tasks_v2.py @@ -0,0 +1,34 @@ +"""Fixed v2 task matrix shared by all competitor adapters.""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class TaskSpec: + task_id: str + category: str + description: str + weight: float + + +TASKS_V2: tuple[TaskSpec, ...] = ( + TaskSpec("core_geometry", "creation", "Create and inspect deterministic geometry", 1.0), + TaskSpec("modify_query", "editing", "Modify geometry and verify the result", 1.0), + TaskSpec("layers_linetypes", "standards", "Apply standard layers and linetypes", 1.0), + TaskSpec("dimensions", "annotation", "Create real associative dimensions", 1.0), + TaskSpec("table_mleader", "annotation", "Create TABLE and MLEADER semantics", 1.0), + TaskSpec("transactions", "reliability", "Rollback a bounded change", 1.0), + TaskSpec("preflight", "planning", "Resolve and hash drawing requirements", 1.0), + TaskSpec("quality_refiner", "quality", "Detect and repair a quality defect", 1.0), + TaskSpec("dxf_roundtrip", "interoperability", "Save and re-open with structural parity", 1.0), + TaskSpec("auditable_delivery", "delivery", "Produce a hashed validation manifest", 1.0), +) + + +def task_by_id(task_id: str) -> TaskSpec: + for task in TASKS_V2: + if task.task_id == task_id: + return task + raise KeyError(f"Unknown benchmark task: {task_id}") diff --git a/docs/superpowers/specs/2026-07-17-repository-cleanup-readme-benchmark-design.md b/docs/superpowers/specs/2026-07-17-repository-cleanup-readme-benchmark-design.md index 973c261..151abde 100644 --- a/docs/superpowers/specs/2026-07-17-repository-cleanup-readme-benchmark-design.md +++ b/docs/superpowers/specs/2026-07-17-repository-cleanup-readme-benchmark-design.md @@ -1,6 +1,6 @@ # Repository Cleanup, README, and Benchmark Design -**Date:** 2026-07-17 +**Date:** 2026-07-17 **Status:** Approved for implementation ## Objective diff --git a/engineering/__init__.py b/engineering/__init__.py index 0447330..7d384f2 100644 --- a/engineering/__init__.py +++ b/engineering/__init__.py @@ -1,5 +1,12 @@ """Deterministic engineering CAD layer for AutoCAD MCP Pro.""" +from .annotation import TableLayout, prepare_table_layout, validate_mleader +from .delivery import ( + DeliveryResult, + compare_drawing_snapshots, + deliver_drawing, + drawing_snapshot, +) from .gear import ( draw_gear_section_aa, draw_helical_gear_front_view, @@ -31,11 +38,25 @@ from .plan_spec import ( SheetSize, SnapType, ) +from .preflight import ( + PreflightConflict, + PreflightQuestion, + PreflightResult, + preflight_drawing, +) +from .refiner import RefineResult, RefineRound, RepairAction, refine_drawing from .titleblock import TitleBlockMetadata, apply_iso_a3_titleblock from .validator import DrawingValidator, ValidationFinding, ValidationResult __all__ = [ "draw_helical_gear_front_view", + "DeliveryResult", + "compare_drawing_snapshots", + "deliver_drawing", + "drawing_snapshot", + "TableLayout", + "prepare_table_layout", + "validate_mleader", "draw_spur_gear_front_view", "draw_gear_section_aa", "generate_full_gear_outline", @@ -63,4 +84,12 @@ __all__ = [ "SnapType", "ALL_CRITIQUE_FOCUSES", "ISO_128_LINEWEIGHTS_MM", + "PreflightConflict", + "PreflightQuestion", + "PreflightResult", + "preflight_drawing", + "RefineResult", + "RefineRound", + "RepairAction", + "refine_drawing", ] diff --git a/engineering/annotation.py b/engineering/annotation.py new file mode 100644 index 0000000..5dac767 --- /dev/null +++ b/engineering/annotation.py @@ -0,0 +1,87 @@ +"""Shared validation and layout math for tables and multileaders.""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class TableLayout: + cells: list[list[str]] + column_widths: list[float] + row_height: float + text_height: float + + @property + def row_count(self) -> int: + return len(self.cells) + + @property + def column_count(self) -> int: + return len(self.column_widths) + + @property + def width(self) -> float: + return sum(self.column_widths) + + @property + def height(self) -> float: + return self.row_count * self.row_height + + +def prepare_table_layout( + rows: list[list[str]], + headers: list[str] | None = None, + column_widths: list[float] | None = None, + row_height: float = 7.0, + text_height: float = 2.5, + title: str | None = None, +) -> TableLayout: + """Validate table input and calculate deterministic cross-backend sizes.""" + if not rows: + raise RuntimeError("entity_create_table: at least one data row is required") + if len(rows) > 200: + raise RuntimeError("entity_create_table: maximum is 200 rows") + if row_height <= 0 or text_height <= 0: + raise RuntimeError("entity_create_table: row_height and text_height must be positive") + + column_count = len(headers) if headers is not None else len(rows[0]) + if column_count < 1 or column_count > 30: + raise RuntimeError("entity_create_table: table must contain between 1 and 30 columns") + if any(len(row) != column_count for row in rows): + raise RuntimeError("entity_create_table: all rows must have the same number of columns") + if headers is not None and len(headers) != column_count: + raise RuntimeError("entity_create_table: headers must match the data column count") + + data = [[str(cell) for cell in row] for row in rows] + if headers is not None: + data.insert(0, [str(cell) for cell in headers]) + if title: + data.insert(0, [str(title)] + [""] * (column_count - 1)) + + if column_widths is None: + widths = [] + for column in range(column_count): + longest = max(len(row[column]) for row in data) + widths.append(max(10.0, longest * float(text_height) * 0.7)) + else: + if len(column_widths) != column_count: + raise RuntimeError("entity_create_table: column_widths must match column count") + widths = [float(width) for width in column_widths] + if any(width <= 0 for width in widths): + raise RuntimeError("entity_create_table: column widths must be positive") + + return TableLayout(data, widths, float(row_height), float(text_height)) + + +def validate_mleader(points: list[list[float]], text: str) -> list[tuple[float, float]]: + if len(points) < 2: + raise RuntimeError("leader_create_mleader: at least two points are required") + if not str(text).strip(): + raise RuntimeError("leader_create_mleader: text must not be empty") + normalized = [] + for point in points: + if len(point) < 2: + raise RuntimeError("leader_create_mleader: each point must contain x and y") + normalized.append((float(point[0]), float(point[1]))) + return normalized diff --git a/engineering/delivery.py b/engineering/delivery.py new file mode 100644 index 0000000..042f1bc --- /dev/null +++ b/engineering/delivery.py @@ -0,0 +1,320 @@ +"""Auditable drawing delivery with artifact hashes and DXF reopen parity.""" + +from __future__ import annotations + +import hashlib +import json +import subprocess +import time +from collections import Counter +from dataclasses import dataclass +from datetime import UTC, datetime +from pathlib import Path +from typing import TYPE_CHECKING, Any + +from version import __version__ + +from .scoring import combine +from .validator import DrawingValidator + +if TYPE_CHECKING: + from backends.base import AutoCADBackend + + +_FORMAT_FILENAMES = { + "dxf": "drawing.dxf", + "pdf": "drawing.pdf", + "png": "preview.png", +} + + +@dataclass +class DeliveryResult: + status: str + output_dir: str + manifest_path: str + artifacts: list[dict] + score: dict + parity: dict + + def to_dict(self) -> dict: + return { + "status": self.status, + "output_dir": self.output_dir, + "manifest_path": self.manifest_path, + "artifacts": list(self.artifacts), + "score": dict(self.score), + "parity": dict(self.parity), + } + + +def _sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def _git_sha() -> str | None: + try: + result = subprocess.run( + ["git", "rev-parse", "HEAD"], + check=True, + capture_output=True, + text=True, + timeout=2, + ) + return result.stdout.strip() or None + except (OSError, subprocess.SubprocessError): + return None + + +async def drawing_snapshot(backend: AutoCADBackend) -> dict[str, Any]: + """Return the stable structural fields used for save/reopen comparison.""" + await backend._ensure_document_state() + entities = await backend.entity_list(limit=100_000) + info = await backend.drawing_info() + listed_entity_count = len(entities) + reported_entity_count = int(info.entity_count) + return { + "entity_count": reported_entity_count, + "listed_entity_count": listed_entity_count, + "inventory_complete": listed_entity_count == reported_entity_count, + "types": dict(sorted(Counter(item.type for item in entities).items())), + "layers": dict(sorted(Counter(item.layer for item in entities).items())), + "extents_min": [float(value) for value in info.extents_min], + "extents_max": [float(value) for value in info.extents_max], + } + + +def compare_drawing_snapshots( + source: dict[str, Any], + reopened: dict[str, Any], + *, + tolerance: float = 1e-6, +) -> dict: + """Compare counts exactly and drawing extents within ``tolerance``.""" + differences: list[dict] = [] + for label, snapshot in (("source", source), ("reopened", reopened)): + if snapshot.get("inventory_complete") is False: + differences.append( + { + "field": f"{label}_inventory_complete", + "entity_count": snapshot.get("entity_count"), + "listed_entity_count": snapshot.get("listed_entity_count"), + } + ) + for field in ("entity_count", "types", "layers"): + if source.get(field) != reopened.get(field): + differences.append( + {"field": field, "source": source.get(field), "reopened": reopened.get(field)} + ) + for field in ("extents_min", "extents_max"): + left = source.get(field, []) + right = reopened.get(field, []) + equal = len(left) == len(right) and all( + abs(float(a) - float(b)) <= tolerance for a, b in zip(left, right, strict=True) + ) + if not equal: + differences.append({"field": field, "source": left, "reopened": right}) + return { + "ok": not differences, + "tolerance": tolerance, + "source": source, + "reopened": reopened, + "differences": differences, + } + + +def _artifact_record(fmt: str, path: Path, *, status: str, error: str | None = None) -> dict: + record = { + "format": fmt, + "filename": path.name, + "status": status, + "size_bytes": path.stat().st_size if path.is_file() else 0, + "sha256": _sha256(path) if path.is_file() else None, + } + if error: + record["error"] = error + return record + + +async def _reopen_parity(source: dict, dxf_path: Path) -> dict: + from backends.ezdxf_backend import EzdxfBackend + + reopened_backend = EzdxfBackend() + await reopened_backend.connect() + try: + await reopened_backend.drawing_open(str(dxf_path)) + reopened = await drawing_snapshot(reopened_backend) + finally: + await reopened_backend.disconnect() + return compare_drawing_snapshots(source, reopened) + + +async def deliver_drawing( + backend: AutoCADBackend, + output_dir: str | Path, + *, + formats: list[str] | None = None, + min_score: float = 95.0, + strict_critique: bool = True, + expected: dict | None = None, +) -> DeliveryResult: + """Export, validate, hash and re-open a release-ready drawing bundle.""" + requested = list(dict.fromkeys(item.lower().strip() for item in (formats or ["dxf", "pdf", "png"]))) + unknown = [item for item in requested if item not in _FORMAT_FILENAMES] + if unknown: + raise ValueError(f"Unsupported delivery format(s): {', '.join(unknown)}") + if not 0 <= float(min_score) <= 100: + raise ValueError("min_score must be between 0 and 100") + + destination = Path(output_dir).resolve() + destination.mkdir(parents=True, exist_ok=True) + if not destination.is_dir(): + raise ValueError(f"Delivery output is not a directory: {destination}") + + started = time.perf_counter() + timings: dict[str, float] = {} + artifacts: list[dict] = [] + capabilities = backend.capabilities().to_dict() + feature_caps = capabilities["features"] + source = await drawing_snapshot(backend) + + # DXF is always emitted: it is the canonical, re-openable parity artifact. + export_formats = ["dxf", *(item for item in requested if item != "dxf")] + for fmt in export_formats: + artifact_path = destination / _FORMAT_FILENAMES[fmt] + capability = feature_caps.get(fmt, {"supported": False, "reason": "not_declared"}) + export_started = time.perf_counter() + if not capability.get("supported"): + artifacts.append( + _artifact_record( + fmt, + artifact_path, + status="unsupported", + error=str(capability.get("reason") or "backend does not support format"), + ) + ) + continue + try: + if fmt == "dxf": + await backend.drawing_export_dxf(str(artifact_path)) + elif fmt == "pdf": + await backend.drawing_export_pdf(str(artifact_path)) + else: + png = await backend.view_screenshot() + if not png: + raise RuntimeError("backend returned no PNG bytes") + artifact_path.write_bytes(png) + artifacts.append(_artifact_record(fmt, artifact_path, status="created")) + except Exception as exc: + artifacts.append( + _artifact_record(fmt, artifact_path, status="failed", error=str(exc)) + ) + finally: + timings[f"export_{fmt}_ms"] = round((time.perf_counter() - export_started) * 1000, 2) + + validation_started = time.perf_counter() + validation = await DrawingValidator().run(backend, expected=expected or {}) + critique_issues = await backend.drawing_critique(focus=None) + critique_summary = {severity: 0 for severity in ("error", "warning", "info")} + for issue in critique_issues: + critique_summary[issue.severity] += 1 + + dxf_record = next(item for item in artifacts if item["format"] == "dxf") + if dxf_record["status"] == "created": + try: + parity = await _reopen_parity(source, destination / dxf_record["filename"]) + except Exception as exc: + parity = { + "ok": False, + "tolerance": 1e-6, + "source": source, + "reopened": None, + "differences": [{"field": "reopen", "error": str(exc)}], + } + else: + parity = { + "ok": False, + "tolerance": 1e-6, + "source": source, + "reopened": None, + "differences": [{"field": "dxf", "error": "canonical DXF was not created"}], + } + + score = combine( + validation.summary, + critique_summary, + {"error": 0 if parity["ok"] else 1}, + ) + timings["validation_ms"] = round((time.perf_counter() - validation_started) * 1000, 2) + + failed_export = any(item["status"] != "created" for item in artifacts) + critique_blocked = any( + issue.severity == "error" or (strict_critique and issue.severity != "info") + for issue in critique_issues + ) + validation_blocked = ( + not validation.ok + or not parity["ok"] + or score["score"] < float(min_score) + or critique_blocked + ) + if failed_export: + status = "failed_export" + elif validation_blocked: + status = "failed_validation" + else: + status = "success" + + validation_payload = { + "validator": validation.to_dict(), + "critique": [issue.to_dict() for issue in critique_issues], + "critique_summary": critique_summary, + "score": score, + "parity": parity, + } + validation_path = destination / "validation.json" + validation_path.write_text( + json.dumps(validation_payload, ensure_ascii=False, indent=2, sort_keys=True), + encoding="utf-8", + ) + + plan = backend.get_plan_spec() + timings["total_ms"] = round((time.perf_counter() - started) * 1000, 2) + manifest = { + "schema_version": "1.0", + "status": status, + "version": __version__, + "git_sha": _git_sha(), + "generated_at": datetime.now(UTC).isoformat(), + "backend": backend.name, + "capabilities": capabilities, + "spec_hash": plan.get("spec_hash") if plan else None, + "requested_formats": requested, + "min_score": float(min_score), + "strict_critique": strict_critique, + "score": score, + "validator": validation.to_dict(), + "critique": validation_payload["critique"], + "critique_summary": critique_summary, + "parity": parity, + "artifacts": artifacts, + "validation_artifact": _artifact_record("json", validation_path, status="created"), + "timings_ms": timings, + } + manifest_path = destination / "manifest.json" + manifest_path.write_text( + json.dumps(manifest, ensure_ascii=False, indent=2, sort_keys=True), + encoding="utf-8", + ) + return DeliveryResult( + status=status, + output_dir=str(destination), + manifest_path=str(manifest_path), + artifacts=artifacts, + score=score, + parity=parity, + ) diff --git a/engineering/plan_spec.py b/engineering/plan_spec.py index b11f175..5e9f147 100644 --- a/engineering/plan_spec.py +++ b/engineering/plan_spec.py @@ -50,6 +50,9 @@ class PlanSpec: view_count: int = 1 dim_style: DimStyle = "chain" notes: list[str] = field(default_factory=list) + requirements: dict = field(default_factory=dict) + spec_hash: str | None = None + preflight_status: Literal["ready", "skipped"] = "skipped" def to_dict(self) -> dict: return { @@ -60,6 +63,9 @@ class PlanSpec: "view_count": self.view_count, "dim_style": self.dim_style, "notes": list(self.notes), + "requirements": dict(self.requirements), + "spec_hash": self.spec_hash, + "preflight_status": self.preflight_status, } diff --git a/engineering/preflight.py b/engineering/preflight.py new file mode 100644 index 0000000..adb212f --- /dev/null +++ b/engineering/preflight.py @@ -0,0 +1,218 @@ +"""Pure, deterministic drawing-requirement preflight validation.""" + +from __future__ import annotations + +import hashlib +import json +import math +from dataclasses import dataclass, field +from numbers import Real +from typing import Any + + +@dataclass(frozen=True) +class PreflightQuestion: + code: str + field: str + question: str + choices: list[str] = field(default_factory=list) + + def to_dict(self) -> dict: + return { + "code": self.code, + "field": self.field, + "question": self.question, + "choices": list(self.choices), + } + + +@dataclass(frozen=True) +class PreflightConflict: + code: str + field: str + values: list[Any] + message: str + + def to_dict(self) -> dict: + return { + "code": self.code, + "field": self.field, + "values": list(self.values), + "message": self.message, + } + + +@dataclass +class PreflightResult: + ready: bool + normalized_spec: dict + questions: list[PreflightQuestion] + conflicts: list[PreflightConflict] + assumptions: list[dict] + spec_hash: str + + def to_dict(self) -> dict: + return { + "ready": self.ready, + "normalized_spec": self.normalized_spec, + "questions": [item.to_dict() for item in self.questions], + "conflicts": [item.to_dict() for item in self.conflicts], + "assumptions": list(self.assumptions), + "spec_hash": self.spec_hash, + } + + +def _normalize(value: Any) -> Any: + if isinstance(value, dict): + return {str(key).strip(): _normalize(item) for key, item in sorted(value.items())} + if isinstance(value, list): + return [_normalize(item) for item in value] + if isinstance(value, str): + return value.strip() + if isinstance(value, Real) and not isinstance(value, bool): + return float(value) + return value + + +def _spec_hash(spec: dict) -> str: + encoded = json.dumps(spec, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + return "sha256:" + hashlib.sha256(encoded.encode("utf-8")).hexdigest() + + +def preflight_drawing( + intent: str, + requirements: dict | None = None, + sheet_size: str = "A3", + scale: float = 1.0, + layer_set_id: str = "mech", + view_count: int = 1, + dim_style: str = "chain", + *, + allow_assumptions: bool = False, +) -> PreflightResult: + """Normalize a drawing request and surface missing or conflicting facts.""" + req = _normalize(dict(requirements or {})) + assumptions: list[dict] = [] + + if allow_assumptions and not req.get("units"): + req["units"] = "mm" + assumptions.append({"field": "units", "value": "mm", "reason": "default"}) + if allow_assumptions and not req.get("tolerance_policy"): + req["tolerance_policy"] = "general" + assumptions.append( + {"field": "tolerance_policy", "value": "general", "reason": "default"} + ) + + if isinstance(req.get("units"), str): + req["units"] = req["units"].lower() + if isinstance(req.get("part_type"), str): + req["part_type"] = req["part_type"].lower() + + questions: list[PreflightQuestion] = [] + required = ( + ("units", "MISSING_UNITS", "Çizim birimi nedir?", ["mm", "inch"]), + ("part_type", "MISSING_PART_TYPE", "Üretilecek parça/çizim türü nedir?", []), + ("dimensions", "MISSING_DIMENSIONS", "Ana üretim ölçüleri nelerdir?", []), + ( + "tolerance_policy", + "MISSING_TOLERANCE_POLICY", + "Hangi tolerans politikası kullanılmalı?", + ["ISO 2768-m", "explicit", "none"], + ), + ) + for field_name, code, question, choices in required: + value = req.get(field_name) + if value is None or value == "" or value == {} or value == []: + questions.append(PreflightQuestion(code, field_name, question, choices)) + + conflicts: list[PreflightConflict] = [] + seen_constraints: dict[str, Any] = {} + for constraint in req.get("constraints", []) or []: + if not isinstance(constraint, dict): + continue + field_name = str(constraint.get("field", "")).strip() + if not field_name or "value" not in constraint: + continue + value = _normalize(constraint["value"]) + if field_name in seen_constraints and seen_constraints[field_name] != value: + conflicts.append( + PreflightConflict( + "CONFLICTING_REQUIREMENT", + field_name, + [seen_constraints[field_name], value], + f"{field_name} için birbiriyle çelişen değerler verildi.", + ) + ) + else: + seen_constraints[field_name] = value + + normalized_sheet = str(sheet_size).strip().upper() + normalized_layer_set = str(layer_set_id).strip().lower() + normalized_dim_style = str(dim_style).strip().lower() + try: + normalized_scale = float(scale) + except (TypeError, ValueError): + normalized_scale = float("nan") + try: + normalized_view_count = int(view_count) + except (TypeError, ValueError): + normalized_view_count = 0 + + parameter_rules = ( + ( + normalized_sheet in {"A4", "A3", "A2", "A1", "A0"}, + "INVALID_SHEET_SIZE", + "sheet_size", + sheet_size, + "sheet_size must be one of A4, A3, A2, A1, A0.", + ), + ( + math.isfinite(normalized_scale) and normalized_scale > 0, + "INVALID_SCALE", + "scale", + scale, + "scale must be a finite number greater than zero.", + ), + ( + 1 <= normalized_view_count <= 20, + "INVALID_VIEW_COUNT", + "view_count", + view_count, + "view_count must be between 1 and 20.", + ), + ( + normalized_dim_style in {"chain", "baseline", "ordinate", "mixed"}, + "INVALID_DIM_STYLE", + "dim_style", + dim_style, + "dim_style must be chain, baseline, ordinate, or mixed.", + ), + ( + normalized_layer_set in {"iso13567", "mech", "pid"}, + "INVALID_LAYER_SET", + "layer_set_id", + layer_set_id, + "layer_set_id must be iso13567, mech, or pid.", + ), + ) + for valid, code, field_name, value, message in parameter_rules: + if not valid: + conflicts.append(PreflightConflict(code, field_name, [value], message)) + + normalized_spec = { + "intent": str(intent).strip(), + "requirements": req, + "sheet_size": normalized_sheet, + "scale": normalized_scale if math.isfinite(normalized_scale) else str(scale).strip(), + "layer_set_id": normalized_layer_set, + "view_count": normalized_view_count, + "dim_style": normalized_dim_style, + } + return PreflightResult( + ready=not questions and not conflicts, + normalized_spec=normalized_spec, + questions=questions, + conflicts=conflicts, + assumptions=assumptions, + spec_hash=_spec_hash(normalized_spec), + ) diff --git a/engineering/refiner.py b/engineering/refiner.py new file mode 100644 index 0000000..7473fd1 --- /dev/null +++ b/engineering/refiner.py @@ -0,0 +1,271 @@ +"""Bounded critique -> repair -> re-critique orchestration.""" + +from __future__ import annotations + +import hashlib +import json +from collections.abc import Awaitable, Callable +from dataclasses import dataclass, field +from typing import TYPE_CHECKING + +from .plan_spec import Issue +from .repairs import ( + repair_construction_left, + repair_dim_overlap, + repair_duplicate_entities, + repair_iso128, + repair_layer_color, + repair_untrimmed_corner, +) +from .scoring import score_findings + +if TYPE_CHECKING: + from backends.base import AutoCADBackend + +RepairHandler = Callable[["AutoCADBackend", Issue], Awaitable[dict]] + + +@dataclass +class RepairAction: + focus: str + fingerprint: str + handles: list[str] + status: str + detail: dict = field(default_factory=dict) + + def to_dict(self) -> dict: + return { + "focus": self.focus, + "fingerprint": self.fingerprint, + "handles": list(self.handles), + "status": self.status, + "detail": self.detail, + } + + +@dataclass +class RefineRound: + number: int + score_before: float + score_after: float + error_count_before: int + error_count_after: int + transaction: str + actions: list[RepairAction] + + def to_dict(self) -> dict: + return { + "number": self.number, + "score_before": self.score_before, + "score_after": self.score_after, + "error_count_before": self.error_count_before, + "error_count_after": self.error_count_after, + "transaction": self.transaction, + "actions": [action.to_dict() for action in self.actions], + } + + +@dataclass +class RefineResult: + status: str + initial_score: float + final_score: float + rounds: list[RefineRound] + remaining_issues: list[dict] + + def to_dict(self) -> dict: + return { + "status": self.status, + "initial_score": self.initial_score, + "final_score": self.final_score, + "rounds": [item.to_dict() for item in self.rounds], + "remaining_issues": list(self.remaining_issues), + } + + +class RepairRegistry: + def __init__(self, *, defaults: bool = False): + self._handlers: dict[str, RepairHandler] = {} + if defaults: + self.register("construction_left", repair_construction_left) + self.register("duplicate_entities", repair_duplicate_entities) + self.register("layer_color", repair_layer_color) + self.register("iso128", repair_iso128) + self.register("untrimmed_corner", repair_untrimmed_corner) + self.register("dim_overlap", repair_dim_overlap) + + def register(self, focus: str, handler: RepairHandler) -> None: + self._handlers[focus] = handler + + def get(self, focus: str) -> RepairHandler | None: + return self._handlers.get(focus) + + +def issue_fingerprint(issue: Issue) -> str: + payload = { + "focus": issue.focus, + "handles": sorted(issue.handles), + "detail": issue.detail, + } + encoded = json.dumps(payload, sort_keys=True, separators=(",", ":"), default=str) + return hashlib.sha256(encoded.encode("utf-8")).hexdigest() + + +def _metrics(issues: list[Issue]) -> tuple[float, int]: + errors = sum(issue.severity == "error" for issue in issues) + warnings = sum(issue.severity == "warning" for issue in issues) + info = sum(issue.severity == "info" for issue in issues) + return float(score_findings(errors, warnings, info)["score"]), errors + + +async def refine_drawing( + backend: AutoCADBackend, + max_rounds: int = 3, + min_score: float = 95.0, + focus: list[str] | None = None, + allowed_repairs: list[str] | None = None, + dry_run: bool = False, + *, + registry: RepairRegistry | None = None, +) -> RefineResult: + """Apply deterministic repairs without allowing quality to regress.""" + max_rounds = max(1, min(3, int(max_rounds))) + min_score = max(0.0, min(100.0, float(min_score))) + registry = registry or RepairRegistry(defaults=True) + allowed = set(allowed_repairs) if allowed_repairs is not None else None + + issues = await backend.drawing_critique(focus) + initial_score, _initial_errors = _metrics(issues) + rounds: list[RefineRound] = [] + + if initial_score >= min_score: + return RefineResult( + "threshold_met", initial_score, initial_score, [], [item.to_dict() for item in issues] + ) + + if dry_run: + actions = [] + for issue in issues: + handler = registry.get(issue.focus) + permitted = allowed is None or issue.focus in allowed + actions.append( + RepairAction( + issue.focus, + issue_fingerprint(issue), + list(issue.handles), + "planned" if handler is not None and permitted else "manual_required", + ) + ) + score, errors = _metrics(issues) + rounds.append(RefineRound(1, score, score, errors, errors, "not_started", actions)) + return RefineResult("dry_run", initial_score, score, rounds, [i.to_dict() for i in issues]) + + status = "max_rounds" + for number in range(1, max_rounds + 1): + score_before, errors_before = _metrics(issues) + candidates: list[tuple[Issue, RepairHandler]] = [] + for issue in issues: + handler = registry.get(issue.focus) + if handler is not None and (allowed is None or issue.focus in allowed): + candidates.append((issue, handler)) + if not candidates: + status = "manual_required" + break + + transaction = await backend.transaction_begin() + actions: list[RepairAction] = [] + if not isinstance(transaction, dict) or not transaction.get("ok"): + detail = transaction if isinstance(transaction, dict) else {"result": transaction} + actions.append(RepairAction("internal", "", [], "failed", detail)) + rounds.append( + RefineRound( + number, + score_before, + score_before, + errors_before, + errors_before, + "not_started", + actions, + ) + ) + status = "transaction_unavailable" + break + try: + for issue, handler in candidates: + detail = await handler(backend, issue) + actions.append( + RepairAction( + issue.focus, + issue_fingerprint(issue), + list(issue.handles), + "applied", + detail, + ) + ) + after = await backend.drawing_critique(focus) + score_after, errors_after = _metrics(after) + except Exception as exc: + await backend.transaction_rollback() + actions.append(RepairAction("internal", "", [], "failed", {"error": str(exc)})) + rounds.append( + RefineRound( + number, + score_before, + score_before, + errors_before, + errors_before, + "rolled_back", + actions, + ) + ) + status = "repair_failed" + issues = await backend.drawing_critique(focus) + break + + if score_after < score_before or errors_after > errors_before: + await backend.transaction_rollback() + rounds.append( + RefineRound( + number, + score_before, + score_before, + errors_before, + errors_before, + "rolled_back", + actions, + ) + ) + status = "rolled_back" + issues = await backend.drawing_critique(focus) + break + + await backend.transaction_commit() + rounds.append( + RefineRound( + number, + score_before, + score_after, + errors_before, + errors_after, + "committed", + actions, + ) + ) + before_fingerprints = {issue_fingerprint(item) for item in issues} + after_fingerprints = {issue_fingerprint(item) for item in after} + issues = after + if score_after >= min_score: + status = "threshold_met" + break + if after_fingerprints == before_fingerprints: + status = "no_progress" + break + + final_score, _final_errors = _metrics(issues) + return RefineResult( + status, + initial_score, + final_score, + rounds, + [item.to_dict() for item in issues], + ) diff --git a/engineering/repairs.py b/engineering/repairs.py new file mode 100644 index 0000000..7859470 --- /dev/null +++ b/engineering/repairs.py @@ -0,0 +1,81 @@ +"""Deterministic repair handlers for the bounded drawing refiner.""" + +from __future__ import annotations + +import math +from typing import TYPE_CHECKING + +from .plan_spec import ISO_128_LINEWEIGHTS_MM, Issue + +if TYPE_CHECKING: + from backends.base import AutoCADBackend + + +async def repair_construction_left(backend: AutoCADBackend, issue: Issue) -> dict: + deleted = [] + for handle in issue.handles: + await backend.entity_delete(handle) + deleted.append(handle) + return {"deleted_handles": deleted} + + +async def repair_duplicate_entities(backend: AutoCADBackend, issue: Issue) -> dict: + if len(issue.handles) < 2: + raise RuntimeError("duplicate_entities repair requires two handles") + handle = issue.handles[-1] + await backend.entity_delete(handle) + return {"deleted_handle": handle, "kept_handle": issue.handles[0]} + + +async def repair_layer_color(backend: AutoCADBackend, issue: Issue) -> dict: + layer = str(issue.detail["layer"]) + expected = int(issue.detail["expected"]) + await backend.layer_modify(layer, color=expected) + return {"layer": layer, "color": expected} + + +async def repair_iso128(backend: AutoCADBackend, issue: Issue) -> dict: + layer = str(issue.detail["layer"]) + raw = float(issue.detail["lineweight"]) + millimetres = raw / 100.0 if raw > 2.05 else raw + nearest = min(ISO_128_LINEWEIGHTS_MM, key=lambda item: abs(item - millimetres)) + await backend.layer_modify(layer, lineweight=nearest) + return {"layer": layer, "lineweight_mm": nearest} + + +def _endpoint_kwargs(entity, old_point: list[float], new_point: tuple[float, float]) -> dict: + start = entity.properties.get("start") + end = entity.properties.get("end") + if not start or not end: + raise RuntimeError(f"LINE {entity.handle} is missing endpoints") + old = (float(old_point[0]), float(old_point[1])) + if math.dist((float(start[0]), float(start[1])), old) <= math.dist( + (float(end[0]), float(end[1])), old + ): + return {"x1": new_point[0], "y1": new_point[1]} + return {"x2": new_point[0], "y2": new_point[1]} + + +async def repair_untrimmed_corner(backend: AutoCADBackend, issue: Issue) -> dict: + if len(issue.handles) != 2 or "p1" not in issue.detail or "p2" not in issue.detail: + raise RuntimeError("untrimmed_corner repair requires two handles and endpoint details") + p1 = issue.detail["p1"] + p2 = issue.detail["p2"] + midpoint = ((float(p1[0]) + float(p2[0])) / 2, (float(p1[1]) + float(p2[1])) / 2) + first = await backend.entity_get(issue.handles[0]) + second = await backend.entity_get(issue.handles[1]) + await backend.entity_edit_geometry( + issue.handles[0], **_endpoint_kwargs(first, p1, midpoint) + ) + await backend.entity_edit_geometry( + issue.handles[1], **_endpoint_kwargs(second, p2, midpoint) + ) + return {"handles": list(issue.handles), "corner": list(midpoint)} + + +async def repair_dim_overlap(backend: AutoCADBackend, issue: Issue) -> dict: + if len(issue.handles) < 2: + raise RuntimeError("dim_overlap repair requires two handles") + handle = issue.handles[-1] + await backend.entity_move(handle, 0.0, 6.0) + return {"moved_handle": handle, "dy": 6.0} diff --git a/pyproject.toml b/pyproject.toml index 7c61041..772130b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "autocad-mcp-pro" -version = "1.2.0" +version = "1.3.0" description = "Production-grade AutoCAD MCP server — COM API + ezdxf dual engine" readme = "README.md" requires-python = ">=3.11" @@ -25,7 +25,7 @@ full = [ ] [project.scripts] -autocad-mcp = "server:mcp" +autocad-mcp = "server:main" [tool.uv] dev-dependencies = [ @@ -42,7 +42,7 @@ build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] # Flat layout: no directory matches the project name, so hatchling needs an # explicit file selection (GH issue #2). server.py imports config + security. -only-include = ["server.py", "config.py", "security.py", "backends", "engineering"] +only-include = ["server.py", "config.py", "security.py", "version.py", "backends", "engineering"] [tool.ruff] line-length = 100 diff --git a/server.py b/server.py index f1f5d5a..9c02545 100644 --- a/server.py +++ b/server.py @@ -10,6 +10,7 @@ Usage: from __future__ import annotations +import argparse import json import logging import math @@ -31,6 +32,7 @@ from pydantic import Field import config from security import sanitize_command, sanitize_lisp, validate_path +from version import __version__ # --------------------------------------------------------------------------- # Logging @@ -698,6 +700,49 @@ async def entity_create_mtext( return _dc(result) +@mcp.tool( + annotations={"title": "Create Table", "readOnlyHint": False}, + tags={"entity", "create", "annotation", "table"}, +) +async def entity_create_table( + x: Annotated[float, "Top-left X coordinate"], + y: Annotated[float, "Top-left Y coordinate"], + rows: Annotated[list[list[str]], "Data rows; every row must have the same length"], + headers: Annotated[list[str] | None, "Optional column header row"] = None, + column_widths: Annotated[list[float] | None, "Optional explicit widths per column"] = None, + row_height: Annotated[float, Field(default=7.0, gt=0)] = 7.0, + text_height: Annotated[float, Field(default=2.5, gt=0)] = 2.5, + title: Annotated[str | None, "Optional title row"] = None, + layer: Annotated[str, "Target layer"] = "TEXT", + ctx: Context = None, +) -> dict: + """Create a native COM table or an explicitly-labelled ezdxf composite table.""" + result = await _backend(ctx).entity_create_table( + x, y, rows, headers, column_widths, row_height, text_height, title, layer + ) + return _dc(result) + + +@mcp.tool( + annotations={"title": "Create Multileader", "readOnlyHint": False}, + tags={"entity", "create", "annotation", "leader"}, +) +async def leader_create_mleader( + points: Annotated[list[list[float]], "Leader vertices as [x, y] pairs"], + text: Annotated[str, "Leader annotation text"], + text_height: Annotated[float, Field(default=2.5, gt=0)] = 2.5, + landing_gap: Annotated[float, Field(default=1.0, ge=0)] = 1.0, + arrow_size: Annotated[float, Field(default=2.5, gt=0)] = 2.5, + layer: Annotated[str, "Target layer"] = "DIM", + ctx: Context = None, +) -> dict: + """Create a native COM MLeader or an explicitly-labelled ezdxf composite.""" + result = await _backend(ctx).leader_create_mleader( + points, text, text_height, landing_gap, arrow_size, layer + ) + return _dc(result) + + @mcp.tool( annotations={"title": "Create Hatch", "readOnlyHint": False}, tags={"entity", "create"}, @@ -2230,6 +2275,15 @@ async def system_status(ctx: Context = None) -> dict: return status +@mcp.tool( + annotations={"title": "Backend Capabilities", "readOnlyHint": True}, + tags={"system", "query"}, +) +async def system_capabilities(ctx: Context = None) -> dict: + """Return machine-readable support modes for the active backend.""" + return _backend(ctx).capabilities().to_dict() + + @mcp.tool( annotations={"title": "Get System Variable", "readOnlyHint": True}, tags={"system"}, @@ -2335,11 +2389,12 @@ async def system_about(ctx: Context = None) -> dict: tool_count = await _registered_tool_count() out = { "name": "AutoCAD MCP Pro", - "version": "1.0.0", + "version": __version__, "description": "Production-grade AutoCAD MCP server with dual COM+ezdxf engine", "active_backend": backend_name, "tool_groups": await _tool_groups(), "unsafe_mode": config.settings.dangerous_commands_enabled, + "capabilities": b.capabilities().to_dict()["features"] if b else {}, } # R20: omit total_tools when unknown rather than reporting a fake -1. if tool_count is not None: @@ -2601,6 +2656,55 @@ async def drawing_finalize( return payload +@mcp.tool( + annotations={ + "title": "Drawing: Deliver Auditable Bundle", + "destructiveHint": True, + }, + tags={"engineering", "drawing", "validation", "delivery"}, +) +async def drawing_deliver( + output_dir: Annotated[ + str, + Field(description="Output directory for drawing artifacts and manifest.json."), + ], + formats: Annotated[ + list[str] | None, + Field(default=None, description="Requested formats: dxf, pdf, png."), + ] = None, + min_score: Annotated[ + float, + Field(default=95.0, ge=0.0, le=100.0, description="Minimum accepted quality score."), + ] = 95.0, + strict_critique: Annotated[ + bool, + Field(default=True, description="Block delivery on every non-info critique issue."), + ] = True, + expected: Annotated[ + dict | None, + Field(default=None, description="Optional validator expectations for the drawing."), + ] = None, + ctx: Context = None, +) -> dict: + """Create a hashed, validated delivery bundle and verify DXF save/reopen parity. + + The result status is ``success``, ``failed_validation`` or ``failed_export``. + Failure intentionally keeps all generated artifacts for diagnosis. + """ + from engineering import deliver_drawing + + destination = validate_path(output_dir, allow_write=True) + result = await deliver_drawing( + _backend(ctx), + destination, + formats=formats, + min_score=min_score, + strict_critique=strict_critique, + expected=expected, + ) + return result.to_dict() + + # --------------------------------------------------------------------------- # ── SECTION 13: Premium Drafting Meta-Tools (5x quality multiplier) ───────── # --------------------------------------------------------------------------- @@ -2608,6 +2712,45 @@ async def drawing_finalize( # plan → snap-aware geometry → critique → finalize. # See `.claude/skills/autocad-mcp-premium/` for the full discipline. +@mcp.tool( + annotations={"title": "Drawing: Preflight", "readOnlyHint": True}, + tags={"premium", "planning", "validation"}, +) +async def drawing_preflight( + intent: Annotated[str, "One-line description of what this drawing represents."], + requirements: Annotated[ + dict | None, + "Units, part_type, dimensions, tolerance_policy and optional constraints.", + ] = None, + sheet_size: Annotated[ + str, Field(default="A3", description="A4 / A3 / A2 / A1 / A0.") + ] = "A3", + scale: Annotated[float, Field(default=1.0, gt=0)] = 1.0, + layer_set_id: Annotated[ + str, Field(default="mech", description="mech / pid / iso13567.") + ] = "mech", + view_count: Annotated[int, Field(default=1, ge=1)] = 1, + dim_style: Annotated[ + str, Field(default="chain", description="chain / baseline / ordinate / mixed.") + ] = "chain", + allow_assumptions: Annotated[ + bool, "Allow documented defaults for units and tolerance policy." + ] = False, + ctx: Context = None, +) -> dict: + """Validate and normalize requirements before committing a drawing plan.""" + result = await _backend(ctx).drawing_preflight( + intent, + requirements, + sheet_size, + scale, + layer_set_id, + view_count, + dim_style, + allow_assumptions, + ) + return result.to_dict() + @mcp.tool( annotations={"title": "Drawing: Plan (commit intent before drawing)", "destructiveHint": False}, @@ -2625,6 +2768,10 @@ async def drawing_plan( dim_style: Annotated[str, Field(default="chain", description="Default dimensioning style: chain / baseline / ordinate / mixed.")] = "chain", notes: Annotated[list[str] | None, "Free-form constraint notes."] = None, + requirements: Annotated[dict | None, "Normalized preflight requirements."] = None, + spec_hash: Annotated[ + str | None, "Hash returned by the latest ready drawing_preflight." + ] = None, ctx: Context = None, ) -> dict: """Commit a PlanSpec before any geometry is created. @@ -2635,6 +2782,7 @@ async def drawing_plan( """ plan = await _backend(ctx).drawing_plan( intent, sheet_size, scale, layer_set_id, view_count, dim_style, notes, + requirements, spec_hash, ) return _dc(plan) @@ -2658,6 +2806,34 @@ async def drawing_critique( return [_dc(i) for i in issues] +@mcp.tool( + annotations={"title": "Drawing: Refine", "destructiveHint": True}, + tags={"premium", "validation", "modify"}, +) +async def drawing_refine( + max_rounds: Annotated[int, Field(default=3, ge=1, le=3)] = 3, + min_score: Annotated[float, Field(default=95.0, ge=0, le=100)] = 95.0, + focus: Annotated[list[str] | None, "Critique focuses to repair; None runs all."] = None, + allowed_repairs: Annotated[ + list[str] | None, "Optional allowlist of repair focus names." + ] = None, + dry_run: Annotated[bool, "Return the repair plan without modifying the drawing."] = False, + ctx: Context = None, +) -> dict: + """Run a bounded, transaction-safe critique/repair/re-critique loop.""" + from engineering.refiner import refine_drawing + + result = await refine_drawing( + _backend(ctx), + max_rounds=max_rounds, + min_score=min_score, + focus=focus, + allowed_repairs=allowed_repairs, + dry_run=dry_run, + ) + return result.to_dict() + + @mcp.tool( annotations={"title": "Point: Snap (deterministic OSNAP)", "readOnlyHint": True}, tags={"premium", "snap"}, @@ -3260,24 +3436,24 @@ async def _guarded_run_async(transport=None, *args, **kwargs): mcp.run_async = _guarded_run_async -if __name__ == "__main__": - transport = "stdio" - host = "127.0.0.1" - port = 8000 +def main() -> None: + """Run the MCP server from the installed ``autocad-mcp`` command.""" + parser = argparse.ArgumentParser(description="Run the AutoCAD MCP server") + parser.add_argument("--transport", default="stdio") + parser.add_argument("--host", default="127.0.0.1") + parser.add_argument("--port", type=int, default=8000) + args = parser.parse_args() - args = sys.argv[1:] - if "--transport" in args: - idx = args.index("--transport") - transport = args[idx + 1] - if "--port" in args: - idx = args.index("--port") - port = int(args[idx + 1]) - if "--host" in args: - idx = args.index("--host") - host = args[idx + 1] - - if transport == "stdio": + if args.transport == "stdio": mcp.run() else: - _validate_http_bind(host) - mcp.run(transport=transport, host=host, port=port) + _validate_http_bind(args.host) + mcp.run( + transport=args.transport, + host=args.host, + port=args.port, + ) + + +if __name__ == "__main__": + main() diff --git a/tests/test_benchmark_v2.py b/tests/test_benchmark_v2.py new file mode 100644 index 0000000..7707f21 --- /dev/null +++ b/tests/test_benchmark_v2.py @@ -0,0 +1,108 @@ +"""Fair benchmark v2 runner contract.""" + +from __future__ import annotations + +import asyncio +import subprocess +import sys +from unittest.mock import AsyncMock, patch + +import pytest + +from benchmarks.adapters.autocad_mcp_pro import AutoCADMCPProAdapter +from benchmarks.adapters.base import BenchmarkAdapter, TaskResult +from benchmarks.run_competitors import run_tasks +from benchmarks.tasks_v2 import TaskSpec + + +class FakeAdapter(BenchmarkAdapter): + name = "fake" + + async def setup(self, artifact_dir): + self.artifact_dir = artifact_dir + + async def run_task(self, task): + if task.task_id == "slow": + await asyncio.sleep(0.2) + if task.task_id == "unsupported": + return TaskResult(task_id=task.task_id, status="unsupported", score=0.0) + return TaskResult(task_id=task.task_id, status="pass", score=100.0) + + async def cleanup(self): + return None + + +@pytest.mark.asyncio +async def test_benchmark_timeout_does_not_stop_later_tasks(tmp_path): + tasks = [ + TaskSpec("slow", "reliability", "Times out", 1.0), + TaskSpec("next", "reliability", "Still runs", 1.0), + ] + + report = await run_tasks(FakeAdapter(), tasks, tmp_path, timeout=0.01) + + assert [item["status"] for item in report["results"]] == ["timeout", "pass"] + assert report["summary"]["attempted"] == 2 + assert report["summary"]["passed"] == 1 + assert report["git_sha"] + assert report["environment"]["python"] + + +@pytest.mark.asyncio +async def test_unsupported_tasks_count_as_zero_in_fixed_matrix_score(tmp_path): + tasks = [ + TaskSpec("supported", "coverage", "Implemented", 1.0), + TaskSpec("unsupported", "coverage", "Not implemented", 9.0), + ] + + report = await run_tasks(FakeAdapter(), tasks, tmp_path) + + assert report["summary"]["score"] == 10.0 + assert report["summary"]["supported"] == 1 + assert report["summary"]["coverage_percent"] == 10.0 + + +def test_task_result_has_machine_readable_status(): + result = TaskResult( + task_id="table", + status="unsupported", + score=0.0, + message="TABLE is unavailable", + ) + + assert result.to_dict() == { + "task_id": "table", + "status": "unsupported", + "score": 0.0, + "message": "TABLE is unavailable", + "duration_ms": 0.0, + "artifacts": [], + "metrics": {}, + "stdout_summary": "", + "stderr_summary": "", + } + + +@pytest.mark.asyncio +async def test_reference_adapter_selects_real_com_backend_class(tmp_path): + with patch("backends.com_backend.ComBackend") as backend_class: + backend_class.return_value.connect = AsyncMock() + adapter = AutoCADMCPProAdapter(backend="com") + + await adapter.setup(tmp_path) + + backend_class.assert_called_once_with() + assert adapter.backend is backend_class.return_value + + +@pytest.mark.parametrize("args", [[], ["--help"]]) +def test_correctness_suite_help_is_not_a_traceback(args): + completed = subprocess.run( + [sys.executable, "benchmarks/correctness_suite.py", *args], + capture_output=True, + text=True, + timeout=10, + ) + + assert completed.returncode == 0 + assert "usage" in completed.stdout.lower() diff --git a/tests/test_capabilities.py b/tests/test_capabilities.py new file mode 100644 index 0000000..5fba95b --- /dev/null +++ b/tests/test_capabilities.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +from unittest.mock import patch + +import pytest + +from backends.base import CapabilityMap, FeatureCapability + + +def test_capability_types_serialize_stably(): + caps = CapabilityMap( + backend="demo", + features={"table": FeatureCapability(True, "native")}, + ) + + assert caps.to_dict() == { + "backend": "demo", + "features": { + "table": {"supported": True, "mode": "native", "reason": None}, + }, + } + + +@pytest.mark.asyncio +async def test_ezdxf_capabilities_are_machine_readable(backend): + caps = backend.capabilities().to_dict() + + assert caps["backend"] == "ezdxf" + assert caps["features"]["table"] == { + "supported": True, + "mode": "composite", + "reason": None, + } + assert caps["features"]["mleader"]["mode"] == "composite" + assert caps["features"]["paper_space"]["supported"] is False + assert caps["features"]["solid_3d"]["supported"] is False + + +@pytest.mark.asyncio +async def test_ezdxf_render_capabilities_follow_optional_dependency(backend): + with patch("backends.ezdxf_backend.find_spec", return_value=None): + caps = backend.capabilities().to_dict() + + assert caps["features"]["pdf"] == { + "supported": False, + "mode": None, + "reason": "optional_dependency_missing:matplotlib", + } + assert caps["features"]["png"]["supported"] is False + + +@pytest.mark.asyncio +async def test_system_capabilities_tool_uses_backend_map(backend): + import server + + class Ctx: + lifespan_context = {"backend": backend} + + result = await server.system_capabilities(ctx=Ctx()) + + assert result == backend.capabilities().to_dict() + + +@pytest.mark.asyncio +async def test_system_about_uses_package_version_and_capability_summary(backend): + import server + + class Ctx: + lifespan_context = {"backend": backend} + + result = await server.system_about(ctx=Ctx()) + + assert result["version"] == "1.3.0" + assert result["capabilities"]["table"]["mode"] == "composite" diff --git a/tests/test_com_backend.py b/tests/test_com_backend.py index 73d1829..fcee8af 100644 --- a/tests/test_com_backend.py +++ b/tests/test_com_backend.py @@ -11,6 +11,7 @@ Strategy: from __future__ import annotations import sys +from types import SimpleNamespace from typing import Any from unittest.mock import MagicMock, patch @@ -19,6 +20,34 @@ import pytest from backends.base import normalize_lineweight from backends.com_backend import ComBackend + +@pytest.mark.asyncio +async def test_active_document_switch_clears_com_document_state(monkeypatch): + backend = ComBackend() + documents = SimpleNamespace(Count=1) + app = SimpleNamespace( + Documents=documents, + ActiveDocument=SimpleNamespace(Name="First.dwg", FullName="C:/First.dwg"), + ) + + async def run_inline(function, *args, **kwargs): + return function(*args, **kwargs) + + monkeypatch.setattr(backend, "_run", run_inline) + monkeypatch.setattr("backends.com_backend._acad_app", lambda: app) + + await backend._ensure_document_state() + backend._plan_spec = object() + backend._preflight_result = object() + backend._gdt_datums_defined = {"A"} + app.ActiveDocument = SimpleNamespace(Name="Second.dwg", FullName="C:/Second.dwg") + + await backend._ensure_document_state() + + assert backend._plan_spec is None + assert backend._preflight_result is None + assert backend._gdt_datums_defined == set() + # --------------------------------------------------------------------------- # normalize_lineweight — parametrized table # --------------------------------------------------------------------------- diff --git a/tests/test_delivery.py b/tests/test_delivery.py new file mode 100644 index 0000000..8acc5c6 --- /dev/null +++ b/tests/test_delivery.py @@ -0,0 +1,139 @@ +"""Release delivery contract: artifacts, hashes, validation and DXF parity.""" + +from __future__ import annotations + +import hashlib +import json + +import pytest + +import server +from engineering.delivery import compare_drawing_snapshots, deliver_drawing, drawing_snapshot +from engineering.layers import ensure_engineering_layers, ensure_standard_linetypes + + +async def _clean_drawing(backend) -> None: + await ensure_standard_linetypes(backend) + await ensure_engineering_layers(backend) + await backend.entity_create_line(0, 0, 40, 0, layer="GEOMETRY") + + +@pytest.mark.asyncio +async def test_delivery_writes_hashed_manifest_and_reopen_parity(backend, tmp_path): + await _clean_drawing(backend) + + result = await deliver_drawing( + backend, + tmp_path, + formats=["dxf"], + min_score=0, + strict_critique=False, + ) + + assert result.status == "success" + manifest_path = tmp_path / "manifest.json" + validation_path = tmp_path / "validation.json" + assert manifest_path.is_file() + assert validation_path.is_file() + + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + dxf = next(item for item in manifest["artifacts"] if item["format"] == "dxf") + artifact_path = tmp_path / dxf["filename"] + assert dxf["status"] == "created" + assert dxf["sha256"] == hashlib.sha256(artifact_path.read_bytes()).hexdigest() + assert manifest["parity"]["ok"] is True + assert manifest["version"] == "1.3.0" + assert manifest["capabilities"]["backend"] == "ezdxf" + + +@pytest.mark.asyncio +async def test_delivery_keeps_artifacts_when_validation_fails(backend, tmp_path): + await _clean_drawing(backend) + await backend.construction_xline(0, 0, 45) + + result = await deliver_drawing( + backend, + tmp_path, + formats=["dxf"], + min_score=100, + strict_critique=True, + ) + + assert result.status == "failed_validation" + assert (tmp_path / "drawing.dxf").is_file() + manifest = json.loads((tmp_path / "manifest.json").read_text(encoding="utf-8")) + assert manifest["status"] == "failed_validation" + assert any(issue["focus"] == "construction_left" for issue in manifest["critique"]) + + +def test_snapshot_comparison_reports_type_and_bounds_drift(): + source = { + "entity_count": 2, + "types": {"LINE": 2}, + "layers": {"GEOMETRY": 2}, + "extents_min": [0.0, 0.0], + "extents_max": [10.0, 5.0], + } + reopened = { + "entity_count": 2, + "types": {"LINE": 1, "CIRCLE": 1}, + "layers": {"GEOMETRY": 2}, + "extents_min": [0.0, 0.0], + "extents_max": [10.5, 5.0], + } + + result = compare_drawing_snapshots(source, reopened) + + assert result["ok"] is False + assert {item["field"] for item in result["differences"]} == {"types", "extents_max"} + + +@pytest.mark.asyncio +async def test_snapshot_parity_rejects_incomplete_entity_inventory(backend, monkeypatch): + await backend.entity_create_line(0, 0, 10, 0) + await backend.entity_create_circle(5, 5, 2) + original_entity_list = backend.entity_list + + async def incomplete_entity_list(*args, **kwargs): + return (await original_entity_list(*args, **kwargs))[:1] + + monkeypatch.setattr(backend, "entity_list", incomplete_entity_list) + source = await drawing_snapshot(backend) + reopened = dict(source, inventory_complete=True, listed_entity_count=2) + + result = compare_drawing_snapshots(source, reopened) + + assert source["entity_count"] == 2 + assert source["listed_entity_count"] == 1 + assert source["inventory_complete"] is False + assert result["ok"] is False + assert "source_inventory_complete" in { + item["field"] for item in result["differences"] + } + + +@pytest.mark.asyncio +async def test_delivery_rejects_unknown_format(backend, tmp_path): + await _clean_drawing(backend) + + with pytest.raises(ValueError, match="Unsupported delivery format"): + await deliver_drawing(backend, tmp_path, formats=["step"]) + + +@pytest.mark.asyncio +async def test_server_drawing_deliver_exposes_manifest_tool(backend, tmp_path): + await _clean_drawing(backend) + + class Ctx: + lifespan_context = {"backend": backend} + + result = await server.drawing_deliver( + output_dir=str(tmp_path), + formats=["dxf"], + min_score=0, + strict_critique=False, + ctx=Ctx(), + ) + + assert result["status"] == "success" + assert result["manifest_path"].endswith("manifest.json") diff --git a/tests/test_preflight.py b/tests/test_preflight.py new file mode 100644 index 0000000..d9d4037 --- /dev/null +++ b/tests/test_preflight.py @@ -0,0 +1,166 @@ +from __future__ import annotations + +import pytest + +from engineering.preflight import preflight_drawing + + +def _complete_requirements() -> dict: + return { + "units": "mm", + "part_type": "bracket", + "dimensions": {"width": 80.0, "height": 50.0, "thickness": 8.0}, + "tolerance_policy": "ISO 2768-m", + } + + +def test_preflight_missing_production_fields_is_not_ready(): + result = preflight_drawing("L bracket", requirements={}) + + assert result.ready is False + assert {q.code for q in result.questions} == { + "MISSING_UNITS", + "MISSING_PART_TYPE", + "MISSING_DIMENSIONS", + "MISSING_TOLERANCE_POLICY", + } + assert result.spec_hash.startswith("sha256:") + + +def test_preflight_normalizes_complete_spec_and_hash_is_deterministic(): + first = preflight_drawing( + " L bracket ", + requirements=_complete_requirements(), + sheet_size="a3", + layer_set_id="MECH", + ) + second = preflight_drawing( + "L bracket", + requirements={ + "tolerance_policy": "ISO 2768-m", + "dimensions": {"thickness": 8, "height": 50, "width": 80}, + "part_type": "bracket", + "units": "MM", + }, + sheet_size="A3", + layer_set_id="mech", + ) + + assert first.ready is True + assert first.normalized_spec["requirements"]["units"] == "mm" + assert first.spec_hash == second.spec_hash + + +def test_preflight_detects_conflicting_constraints(): + requirements = _complete_requirements() + requirements["constraints"] = [ + {"field": "dimensions.width", "value": 80}, + {"field": "dimensions.width", "value": 90}, + ] + + result = preflight_drawing("L bracket", requirements=requirements) + + assert result.ready is False + assert result.conflicts[0].code == "CONFLICTING_REQUIREMENT" + assert result.conflicts[0].field == "dimensions.width" + + +@pytest.mark.parametrize( + ("kwargs", "code"), + [ + ({"sheet_size": "LETTER"}, "INVALID_SHEET_SIZE"), + ({"scale": 0}, "INVALID_SCALE"), + ({"view_count": 0}, "INVALID_VIEW_COUNT"), + ({"dim_style": "guess"}, "INVALID_DIM_STYLE"), + ({"layer_set_id": "custom"}, "INVALID_LAYER_SET"), + ], +) +def test_preflight_rejects_invalid_planning_parameters(kwargs, code): + result = preflight_drawing( + "L bracket", + requirements=_complete_requirements(), + **kwargs, + ) + + assert result.ready is False + assert code in {conflict.code for conflict in result.conflicts} + + +def test_preflight_does_not_invent_values_when_assumptions_disabled(): + result = preflight_drawing( + "L bracket", + requirements={"part_type": "bracket"}, + allow_assumptions=False, + ) + + assert result.assumptions == [] + assert "units" not in result.normalized_spec["requirements"] + + +@pytest.mark.asyncio +async def test_backend_plan_accepts_matching_ready_preflight(backend): + preflight = await backend.drawing_preflight( + "L bracket", requirements=_complete_requirements(), + ) + + plan = await backend.drawing_plan( + "L bracket", + requirements=preflight.normalized_spec["requirements"], + spec_hash=preflight.spec_hash, + ) + + assert plan.preflight_status == "ready" + assert plan.spec_hash == preflight.spec_hash + assert plan.requirements["units"] == "mm" + + +@pytest.mark.asyncio +async def test_backend_plan_rejects_not_ready_preflight_hash(backend): + preflight = await backend.drawing_preflight("L bracket", requirements={}) + + with pytest.raises(RuntimeError, match="not ready"): + await backend.drawing_plan("L bracket", spec_hash=preflight.spec_hash) + + +@pytest.mark.asyncio +async def test_backend_plan_rejects_fields_that_do_not_match_preflight_hash(backend): + preflight = await backend.drawing_preflight( + "L bracket", requirements=_complete_requirements(), + ) + + with pytest.raises(RuntimeError, match="do not match"): + await backend.drawing_plan( + "Different part", + requirements=preflight.normalized_spec["requirements"], + spec_hash=preflight.spec_hash, + ) + + +@pytest.mark.asyncio +async def test_legacy_plan_remains_compatible_and_marks_preflight_skipped(backend): + plan = await backend.drawing_plan("Legacy drawing") + + assert plan.preflight_status == "skipped" + assert plan.spec_hash is None + assert plan.requirements == {} + + +@pytest.mark.asyncio +async def test_document_change_clears_plan_preflight_and_gdt_state(backend): + preflight = await backend.drawing_preflight( + "First drawing", requirements=_complete_requirements(), + ) + await backend.drawing_plan( + "First drawing", + requirements=preflight.normalized_spec["requirements"], + spec_hash=preflight.spec_hash, + ) + backend._gdt_datums_defined = {"A"} + backend._gdt_datums_referenced = {"A", "B"} + + await backend.drawing_new() + + assert backend.get_plan_spec() is None + assert backend._preflight_result is None + assert backend._gdt_datums_defined == set() + assert backend._gdt_datums_referenced == set() diff --git a/tests/test_refiner.py b/tests/test_refiner.py new file mode 100644 index 0000000..7786830 --- /dev/null +++ b/tests/test_refiner.py @@ -0,0 +1,139 @@ +from __future__ import annotations + +import pytest + +from engineering.refiner import RepairRegistry, refine_drawing + +pytestmark = pytest.mark.asyncio + + +async def test_transaction_stack_is_separate_from_drawing_undo(backend): + await backend.entity_create_line(0, 0, 10, 0) + await backend.transaction_begin() + + undo = await backend.drawing_undo() + status = await backend.system_status() + + assert undo["ok"] is False + assert status["transaction_depth"] == 1 + await backend.transaction_rollback() + + +async def test_transaction_rollback_restores_document(backend): + await backend.entity_create_line(0, 0, 10, 0) + await backend.transaction_begin() + await backend.entity_create_circle(5, 5, 2) + + await backend.transaction_rollback() + + entities = await backend.entity_list(limit=100) + assert [entity.type for entity in entities] == ["LINE"] + + +async def test_refiner_does_not_mutate_when_transaction_cannot_start(backend, monkeypatch): + await backend.entity_create_line(0, 0, 20, 0) + await backend.entity_create_line(0, 0, 20, 0) + + async def transaction_rejected(): + return {"ok": False, "error": "A transaction is already active"} + + async def unexpected_transaction_end(): + raise AssertionError("refiner must not end a transaction it did not start") + + monkeypatch.setattr(backend, "transaction_begin", transaction_rejected) + monkeypatch.setattr(backend, "transaction_commit", unexpected_transaction_end) + monkeypatch.setattr(backend, "transaction_rollback", unexpected_transaction_end) + + result = await refine_drawing(backend, focus=["duplicate_entities"]) + + assert result.status == "transaction_unavailable" + assert result.rounds[0].transaction == "not_started" + assert len(await backend.entity_list(type_filter="LINE")) == 2 + + +async def test_refiner_repairs_construction_and_duplicates(backend): + await backend.entity_create_line(0, 0, 20, 0) + await backend.entity_create_line(0, 0, 20, 0) + await backend.construction_xline(0, 0, 0) + + result = await refine_drawing( + backend, + max_rounds=3, + min_score=95, + focus=["duplicate_entities", "construction_left"], + ) + + assert result.status == "threshold_met" + assert result.final_score == 100.0 + assert result.rounds[0].score_after > result.rounds[0].score_before + assert await backend.drawing_critique( + ["duplicate_entities", "construction_left"] + ) == [] + + +async def test_refiner_dry_run_does_not_mutate_drawing(backend): + await backend.entity_create_line(0, 0, 20, 0) + await backend.entity_create_line(0, 0, 20, 0) + + result = await refine_drawing( + backend, + focus=["duplicate_entities"], + dry_run=True, + ) + + assert result.status == "dry_run" + assert len(await backend.entity_list(type_filter="LINE")) == 2 + assert result.rounds[0].actions[0].status == "planned" + + +async def test_refiner_rolls_back_when_custom_repair_lowers_score(backend): + await backend.entity_create_line(0, 0, 20, 0) + await backend.entity_create_line(0, 0, 20, 0) + + registry = RepairRegistry() + + async def harmful_repair(active_backend, issue): + await active_backend.construction_xline(0, 0, 0) + return {"created": "construction"} + + registry.register("duplicate_entities", harmful_repair) + before = len(await backend.entity_list(limit=100)) + + result = await refine_drawing( + backend, + focus=["duplicate_entities", "construction_left"], + registry=registry, + ) + + assert result.status == "rolled_back" + assert result.rounds[0].transaction == "rolled_back" + assert len(await backend.entity_list(limit=100)) == before + + +async def test_refiner_leaves_missing_gdt_datum_for_manual_resolution(backend): + backend._gdt_datums_referenced = {"A"} + + result = await refine_drawing(backend, focus=["gdt"]) + + assert result.status == "manual_required" + assert result.remaining_issues[0]["focus"] == "gdt" + + +async def test_server_drawing_refine_returns_structured_payload(backend): + import server + + class Ctx: + lifespan_context = {"backend": backend} + + await backend.entity_create_line(0, 0, 20, 0) + await backend.entity_create_line(0, 0, 20, 0) + + result = await server.drawing_refine( + max_rounds=3, + min_score=95, + focus=["duplicate_entities"], + ctx=Ctx(), + ) + + assert result["status"] == "threshold_met" + assert result["final_score"] == 100.0 diff --git a/tests/test_server_entrypoint.py b/tests/test_server_entrypoint.py new file mode 100644 index 0000000..bb794bc --- /dev/null +++ b/tests/test_server_entrypoint.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +import sys +import tomllib +from pathlib import Path +from unittest.mock import MagicMock + +import server + + +def test_packaged_console_script_targets_callable_main(): + metadata = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8")) + + assert metadata["project"]["scripts"]["autocad-mcp"] == "server:main" + assert callable(server.main) + + +def test_main_starts_stdio_transport(monkeypatch): + run = MagicMock() + monkeypatch.setattr(server.mcp, "run", run) + monkeypatch.setattr(sys, "argv", ["autocad-mcp"]) + + server.main() + + run.assert_called_once_with() + + +def test_main_help_exits_without_starting_server(monkeypatch, capsys): + run = MagicMock() + monkeypatch.setattr(server.mcp, "run", run) + monkeypatch.setattr(sys, "argv", ["autocad-mcp", "--help"]) + + try: + server.main() + except SystemExit as exc: + assert exc.code == 0 + else: + raise AssertionError("--help must exit without starting the server") + + assert "usage:" in capsys.readouterr().out.lower() + run.assert_not_called() diff --git a/tests/test_sprint4_ezdxf.py b/tests/test_sprint4_ezdxf.py index 6f976fe..3b5be66 100644 --- a/tests/test_sprint4_ezdxf.py +++ b/tests/test_sprint4_ezdxf.py @@ -113,11 +113,12 @@ async def test_rollback_without_transaction_returns_error(backend): async def test_transaction_begin_commit_roundtrip(backend): begin = await backend.transaction_begin() assert begin["ok"] is True - assert len(backend._undo_stack) == 1 + assert len(backend._transaction_stack) == 1 + assert len(backend._undo_stack) == 0 commit = await backend.transaction_commit() assert commit["ok"] is True - assert len(backend._undo_stack) == 0 + assert len(backend._transaction_stack) == 0 async def test_transaction_rollback_restores_snapshot(backend): @@ -129,7 +130,7 @@ async def test_transaction_rollback_restores_snapshot(backend): res = await backend.transaction_rollback() assert res["ok"] is True - assert len(backend._undo_stack) == 0 + assert len(backend._transaction_stack) == 0 # The line is gone after rollback. listed = await backend.entity_list(type_filter="LINE") diff --git a/tests/test_table_leader.py b/tests/test_table_leader.py new file mode 100644 index 0000000..de7dc11 --- /dev/null +++ b/tests/test_table_leader.py @@ -0,0 +1,141 @@ +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest + +from backends.base import EntityInfo +from backends.com_backend import ComBackend + +pytestmark = pytest.mark.asyncio + + +async def test_ezdxf_table_returns_composite_contract(backend): + result = await backend.entity_create_table( + 10, + 100, + rows=[["P-01", "4"], ["P-02", "2"]], + headers=["Part", "Qty"], + title="BOM", + layer="TEXT", + ) + + assert result.type == "TABLE" + assert result.layer == "TEXT" + assert result.properties["representation"] == "composite" + assert result.properties["logical_group_id"].startswith("table:") + assert len(result.properties["child_handles"]) >= 10 + assert result.properties["bounds"] == {"min": [10.0, 72.0], "max": [30.0, 100.0]} + + +async def test_ezdxf_table_survives_save_and_reopen(backend, tmp_path): + result = await backend.entity_create_table( + 0, 30, rows=[["A", "1"]], headers=["Name", "Qty"] + ) + path = tmp_path / "table.dxf" + await backend.drawing_save_as(str(path), fmt="dxf") + await backend.drawing_open(str(path)) + + entities = await backend.entity_list(limit=100) + handles = {entity.handle for entity in entities} + texts = { + entity.properties.get("text") + for entity in entities + if entity.type in {"TEXT", "MTEXT"} + } + assert set(result.properties["child_handles"]).issubset(handles) + assert {"Name", "Qty", "A", "1"}.issubset(texts) + + +async def test_table_rejects_ragged_rows_and_size_limit(backend): + with pytest.raises(RuntimeError, match="same number of columns"): + await backend.entity_create_table(0, 0, rows=[["A", "B"], ["C"]]) + + with pytest.raises(RuntimeError, match="200 rows"): + await backend.entity_create_table(0, 0, rows=[["A"]] * 201) + + +async def test_ezdxf_mleader_returns_composite_contract(backend): + result = await backend.leader_create_mleader( + [[0, 0], [10, 5], [30, 5]], + "SURFACE A", + layer="DIM", + ) + + assert result.type == "MLEADER" + assert result.properties["representation"] == "composite" + assert result.properties["logical_group_id"].startswith("mleader:") + assert result.properties["text"] == "SURFACE A" + assert len(result.properties["child_handles"]) == 3 + + +async def test_mleader_requires_two_points(backend): + with pytest.raises(RuntimeError, match="at least two points"): + await backend.leader_create_mleader([[0, 0]], "note") + + +async def test_com_table_uses_native_add_table(): + backend = ComBackend() + + async def run_inline(func, *args, **kwargs): + return func(*args, **kwargs) + + backend._run = run_inline + mspace = MagicMock() + table = MagicMock() + table.Handle = "A1" + mspace.AddTable.return_value = table + info = EntityInfo("A1", "ACAD_TABLE", "TEXT", 256, "ByLayer", True, {}) + + with ( + patch("backends.com_backend._msp", return_value=mspace), + patch("backends.com_backend._apoint", side_effect=lambda *p: tuple(p)), + patch("backends.com_backend._entity_info", return_value=info), + patch("backends.com_backend._apply_entity_attrs"), + patch("backends.com_backend._regen"), + ): + result = await backend.entity_create_table( + 0, 50, [["A", "1"]], headers=["Name", "Qty"], title="BOM" + ) + + mspace.AddTable.assert_called_once() + assert mspace.AddTable.call_args[0][1:3] == (3, 2) + assert table.SetText.call_count == 6 + assert result.type == "TABLE" + assert result.properties["representation"] == "native" + + +async def test_com_mleader_uses_native_add_mleader(): + backend = ComBackend() + + async def run_inline(func, *args, **kwargs): + return func(*args, **kwargs) + + backend._run = run_inline + mspace = MagicMock() + leader = MagicMock() + leader.Handle = "B1" + mspace.AddMLeader.return_value = leader + info = EntityInfo("B1", "ACAD_MLEADER", "DIM", 256, "ByLayer", True, {}) + + with ( + patch("backends.com_backend._msp", return_value=mspace), + patch("backends.com_backend._av", side_effect=lambda values: values), + patch("backends.com_backend._entity_info", return_value=info), + patch("backends.com_backend._apply_entity_attrs"), + patch("backends.com_backend._regen"), + ): + result = await backend.leader_create_mleader([[0, 0], [20, 5]], "NOTE") + + mspace.AddMLeader.assert_called_once_with([0.0, 0.0, 0.0, 20.0, 5.0, 0.0], 0) + assert leader.TextString == "NOTE" + assert result.type == "MLEADER" + assert result.properties["representation"] == "native" + + +async def test_server_registers_table_and_mleader_tools(): + import server + + names = {tool.name for tool in await server._registered_tools()} + assert "entity_create_table" in names + assert "leader_create_mleader" in names diff --git a/version.py b/version.py new file mode 100644 index 0000000..f98c799 --- /dev/null +++ b/version.py @@ -0,0 +1,20 @@ +"""Package version resolved from installed metadata or the source pyproject.""" + +from __future__ import annotations + +import tomllib +from importlib.metadata import PackageNotFoundError, version +from pathlib import Path + + +def package_version() -> str: + """Return the canonical project version without a duplicate constant.""" + try: + return version("autocad-mcp-pro") + except PackageNotFoundError: + pyproject = Path(__file__).with_name("pyproject.toml") + with pyproject.open("rb") as stream: + return str(tomllib.load(stream)["project"]["version"]) + + +__version__ = package_version()