release: prepare v1.3.0

This commit is contained in:
Umutcan Edizsalan
2026-07-17 15:48:43 +03:00
parent 1d47408157
commit 4a2e9d658f
35 changed files with 3053 additions and 48 deletions
+41 -1
View File
@@ -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
+1 -1
View File
@@ -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
+142
View File
@@ -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:
+162 -3
View File
@@ -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()
+206 -15
View File
@@ -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",
+6 -2
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
"""Benchmark suites for AutoCAD MCP Pro."""
+5
View File
@@ -0,0 +1,5 @@
"""Benchmark adapters."""
from .base import BenchmarkAdapter, TaskResult
__all__ = ["BenchmarkAdapter", "TaskResult"]
+151
View File
@@ -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]
+58
View File
@@ -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: ...
+10
View File
@@ -0,0 +1,10 @@
{
"schema_version": "2.0",
"competitors": [
{
"id": "autocad-mcp-pro",
"adapter": "benchmarks.adapters.autocad_mcp_pro:AutoCADMCPProAdapter",
"backends": ["ezdxf", "com"]
}
]
}
+7
View File
@@ -224,11 +224,18 @@ CHECKS = {
def main():
if len(sys.argv) == 1 or sys.argv[1] in {"-h", "--help"}:
print("usage: correctness_suite.py <check_name> | --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")
+1
View File
@@ -0,0 +1 @@
+156
View File
@@ -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()
+34
View File
@@ -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}")
@@ -1,6 +1,6 @@
# Repository Cleanup, README, and Benchmark Design
**Date:** 2026-07-17
**Date:** 2026-07-17
**Status:** Approved for implementation
## Objective
+29
View File
@@ -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",
]
+87
View File
@@ -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
+320
View File
@@ -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,
)
+6
View File
@@ -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,
}
+218
View File
@@ -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),
)
+271
View File
@@ -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],
)
+81
View File
@@ -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}
+3 -3
View File
@@ -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
+195 -19
View File
@@ -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()
+108
View File
@@ -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()
+74
View File
@@ -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"
+29
View File
@@ -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
# ---------------------------------------------------------------------------
+139
View File
@@ -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")
+166
View File
@@ -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()
+139
View File
@@ -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
+41
View File
@@ -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()
+4 -3
View File
@@ -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")
+141
View File
@@ -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
+20
View File
@@ -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()