release: prepare v1.3.0
This commit is contained in:
+41
-1
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Benchmark suites for AutoCAD MCP Pro."""
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Benchmark adapters."""
|
||||
|
||||
from .base import BenchmarkAdapter, TaskResult
|
||||
|
||||
__all__ = ["BenchmarkAdapter", "TaskResult"]
|
||||
@@ -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]
|
||||
@@ -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: ...
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"schema_version": "2.0",
|
||||
"competitors": [
|
||||
{
|
||||
"id": "autocad-mcp-pro",
|
||||
"adapter": "benchmarks.adapters.autocad_mcp_pro:AutoCADMCPProAdapter",
|
||||
"backends": ["ezdxf", "com"]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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],
|
||||
)
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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")
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
|
||||
@@ -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
@@ -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()
|
||||
Reference in New Issue
Block a user