release: prepare v1.3.0

This commit is contained in:
Umutcan Edizsalan
2026-07-17 15:48:43 +03:00
parent 1d47408157
commit 4a2e9d658f
35 changed files with 3053 additions and 48 deletions
+29
View File
@@ -1,5 +1,12 @@
"""Deterministic engineering CAD layer for AutoCAD MCP Pro."""
from .annotation import TableLayout, prepare_table_layout, validate_mleader
from .delivery import (
DeliveryResult,
compare_drawing_snapshots,
deliver_drawing,
drawing_snapshot,
)
from .gear import (
draw_gear_section_aa,
draw_helical_gear_front_view,
@@ -31,11 +38,25 @@ from .plan_spec import (
SheetSize,
SnapType,
)
from .preflight import (
PreflightConflict,
PreflightQuestion,
PreflightResult,
preflight_drawing,
)
from .refiner import RefineResult, RefineRound, RepairAction, refine_drawing
from .titleblock import TitleBlockMetadata, apply_iso_a3_titleblock
from .validator import DrawingValidator, ValidationFinding, ValidationResult
__all__ = [
"draw_helical_gear_front_view",
"DeliveryResult",
"compare_drawing_snapshots",
"deliver_drawing",
"drawing_snapshot",
"TableLayout",
"prepare_table_layout",
"validate_mleader",
"draw_spur_gear_front_view",
"draw_gear_section_aa",
"generate_full_gear_outline",
@@ -63,4 +84,12 @@ __all__ = [
"SnapType",
"ALL_CRITIQUE_FOCUSES",
"ISO_128_LINEWEIGHTS_MM",
"PreflightConflict",
"PreflightQuestion",
"PreflightResult",
"preflight_drawing",
"RefineResult",
"RefineRound",
"RepairAction",
"refine_drawing",
]
+87
View File
@@ -0,0 +1,87 @@
"""Shared validation and layout math for tables and multileaders."""
from __future__ import annotations
from dataclasses import dataclass
@dataclass(frozen=True)
class TableLayout:
cells: list[list[str]]
column_widths: list[float]
row_height: float
text_height: float
@property
def row_count(self) -> int:
return len(self.cells)
@property
def column_count(self) -> int:
return len(self.column_widths)
@property
def width(self) -> float:
return sum(self.column_widths)
@property
def height(self) -> float:
return self.row_count * self.row_height
def prepare_table_layout(
rows: list[list[str]],
headers: list[str] | None = None,
column_widths: list[float] | None = None,
row_height: float = 7.0,
text_height: float = 2.5,
title: str | None = None,
) -> TableLayout:
"""Validate table input and calculate deterministic cross-backend sizes."""
if not rows:
raise RuntimeError("entity_create_table: at least one data row is required")
if len(rows) > 200:
raise RuntimeError("entity_create_table: maximum is 200 rows")
if row_height <= 0 or text_height <= 0:
raise RuntimeError("entity_create_table: row_height and text_height must be positive")
column_count = len(headers) if headers is not None else len(rows[0])
if column_count < 1 or column_count > 30:
raise RuntimeError("entity_create_table: table must contain between 1 and 30 columns")
if any(len(row) != column_count for row in rows):
raise RuntimeError("entity_create_table: all rows must have the same number of columns")
if headers is not None and len(headers) != column_count:
raise RuntimeError("entity_create_table: headers must match the data column count")
data = [[str(cell) for cell in row] for row in rows]
if headers is not None:
data.insert(0, [str(cell) for cell in headers])
if title:
data.insert(0, [str(title)] + [""] * (column_count - 1))
if column_widths is None:
widths = []
for column in range(column_count):
longest = max(len(row[column]) for row in data)
widths.append(max(10.0, longest * float(text_height) * 0.7))
else:
if len(column_widths) != column_count:
raise RuntimeError("entity_create_table: column_widths must match column count")
widths = [float(width) for width in column_widths]
if any(width <= 0 for width in widths):
raise RuntimeError("entity_create_table: column widths must be positive")
return TableLayout(data, widths, float(row_height), float(text_height))
def validate_mleader(points: list[list[float]], text: str) -> list[tuple[float, float]]:
if len(points) < 2:
raise RuntimeError("leader_create_mleader: at least two points are required")
if not str(text).strip():
raise RuntimeError("leader_create_mleader: text must not be empty")
normalized = []
for point in points:
if len(point) < 2:
raise RuntimeError("leader_create_mleader: each point must contain x and y")
normalized.append((float(point[0]), float(point[1])))
return normalized
+320
View File
@@ -0,0 +1,320 @@
"""Auditable drawing delivery with artifact hashes and DXF reopen parity."""
from __future__ import annotations
import hashlib
import json
import subprocess
import time
from collections import Counter
from dataclasses import dataclass
from datetime import UTC, datetime
from pathlib import Path
from typing import TYPE_CHECKING, Any
from version import __version__
from .scoring import combine
from .validator import DrawingValidator
if TYPE_CHECKING:
from backends.base import AutoCADBackend
_FORMAT_FILENAMES = {
"dxf": "drawing.dxf",
"pdf": "drawing.pdf",
"png": "preview.png",
}
@dataclass
class DeliveryResult:
status: str
output_dir: str
manifest_path: str
artifacts: list[dict]
score: dict
parity: dict
def to_dict(self) -> dict:
return {
"status": self.status,
"output_dir": self.output_dir,
"manifest_path": self.manifest_path,
"artifacts": list(self.artifacts),
"score": dict(self.score),
"parity": dict(self.parity),
}
def _sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as stream:
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def _git_sha() -> str | None:
try:
result = subprocess.run(
["git", "rev-parse", "HEAD"],
check=True,
capture_output=True,
text=True,
timeout=2,
)
return result.stdout.strip() or None
except (OSError, subprocess.SubprocessError):
return None
async def drawing_snapshot(backend: AutoCADBackend) -> dict[str, Any]:
"""Return the stable structural fields used for save/reopen comparison."""
await backend._ensure_document_state()
entities = await backend.entity_list(limit=100_000)
info = await backend.drawing_info()
listed_entity_count = len(entities)
reported_entity_count = int(info.entity_count)
return {
"entity_count": reported_entity_count,
"listed_entity_count": listed_entity_count,
"inventory_complete": listed_entity_count == reported_entity_count,
"types": dict(sorted(Counter(item.type for item in entities).items())),
"layers": dict(sorted(Counter(item.layer for item in entities).items())),
"extents_min": [float(value) for value in info.extents_min],
"extents_max": [float(value) for value in info.extents_max],
}
def compare_drawing_snapshots(
source: dict[str, Any],
reopened: dict[str, Any],
*,
tolerance: float = 1e-6,
) -> dict:
"""Compare counts exactly and drawing extents within ``tolerance``."""
differences: list[dict] = []
for label, snapshot in (("source", source), ("reopened", reopened)):
if snapshot.get("inventory_complete") is False:
differences.append(
{
"field": f"{label}_inventory_complete",
"entity_count": snapshot.get("entity_count"),
"listed_entity_count": snapshot.get("listed_entity_count"),
}
)
for field in ("entity_count", "types", "layers"):
if source.get(field) != reopened.get(field):
differences.append(
{"field": field, "source": source.get(field), "reopened": reopened.get(field)}
)
for field in ("extents_min", "extents_max"):
left = source.get(field, [])
right = reopened.get(field, [])
equal = len(left) == len(right) and all(
abs(float(a) - float(b)) <= tolerance for a, b in zip(left, right, strict=True)
)
if not equal:
differences.append({"field": field, "source": left, "reopened": right})
return {
"ok": not differences,
"tolerance": tolerance,
"source": source,
"reopened": reopened,
"differences": differences,
}
def _artifact_record(fmt: str, path: Path, *, status: str, error: str | None = None) -> dict:
record = {
"format": fmt,
"filename": path.name,
"status": status,
"size_bytes": path.stat().st_size if path.is_file() else 0,
"sha256": _sha256(path) if path.is_file() else None,
}
if error:
record["error"] = error
return record
async def _reopen_parity(source: dict, dxf_path: Path) -> dict:
from backends.ezdxf_backend import EzdxfBackend
reopened_backend = EzdxfBackend()
await reopened_backend.connect()
try:
await reopened_backend.drawing_open(str(dxf_path))
reopened = await drawing_snapshot(reopened_backend)
finally:
await reopened_backend.disconnect()
return compare_drawing_snapshots(source, reopened)
async def deliver_drawing(
backend: AutoCADBackend,
output_dir: str | Path,
*,
formats: list[str] | None = None,
min_score: float = 95.0,
strict_critique: bool = True,
expected: dict | None = None,
) -> DeliveryResult:
"""Export, validate, hash and re-open a release-ready drawing bundle."""
requested = list(dict.fromkeys(item.lower().strip() for item in (formats or ["dxf", "pdf", "png"])))
unknown = [item for item in requested if item not in _FORMAT_FILENAMES]
if unknown:
raise ValueError(f"Unsupported delivery format(s): {', '.join(unknown)}")
if not 0 <= float(min_score) <= 100:
raise ValueError("min_score must be between 0 and 100")
destination = Path(output_dir).resolve()
destination.mkdir(parents=True, exist_ok=True)
if not destination.is_dir():
raise ValueError(f"Delivery output is not a directory: {destination}")
started = time.perf_counter()
timings: dict[str, float] = {}
artifacts: list[dict] = []
capabilities = backend.capabilities().to_dict()
feature_caps = capabilities["features"]
source = await drawing_snapshot(backend)
# DXF is always emitted: it is the canonical, re-openable parity artifact.
export_formats = ["dxf", *(item for item in requested if item != "dxf")]
for fmt in export_formats:
artifact_path = destination / _FORMAT_FILENAMES[fmt]
capability = feature_caps.get(fmt, {"supported": False, "reason": "not_declared"})
export_started = time.perf_counter()
if not capability.get("supported"):
artifacts.append(
_artifact_record(
fmt,
artifact_path,
status="unsupported",
error=str(capability.get("reason") or "backend does not support format"),
)
)
continue
try:
if fmt == "dxf":
await backend.drawing_export_dxf(str(artifact_path))
elif fmt == "pdf":
await backend.drawing_export_pdf(str(artifact_path))
else:
png = await backend.view_screenshot()
if not png:
raise RuntimeError("backend returned no PNG bytes")
artifact_path.write_bytes(png)
artifacts.append(_artifact_record(fmt, artifact_path, status="created"))
except Exception as exc:
artifacts.append(
_artifact_record(fmt, artifact_path, status="failed", error=str(exc))
)
finally:
timings[f"export_{fmt}_ms"] = round((time.perf_counter() - export_started) * 1000, 2)
validation_started = time.perf_counter()
validation = await DrawingValidator().run(backend, expected=expected or {})
critique_issues = await backend.drawing_critique(focus=None)
critique_summary = {severity: 0 for severity in ("error", "warning", "info")}
for issue in critique_issues:
critique_summary[issue.severity] += 1
dxf_record = next(item for item in artifacts if item["format"] == "dxf")
if dxf_record["status"] == "created":
try:
parity = await _reopen_parity(source, destination / dxf_record["filename"])
except Exception as exc:
parity = {
"ok": False,
"tolerance": 1e-6,
"source": source,
"reopened": None,
"differences": [{"field": "reopen", "error": str(exc)}],
}
else:
parity = {
"ok": False,
"tolerance": 1e-6,
"source": source,
"reopened": None,
"differences": [{"field": "dxf", "error": "canonical DXF was not created"}],
}
score = combine(
validation.summary,
critique_summary,
{"error": 0 if parity["ok"] else 1},
)
timings["validation_ms"] = round((time.perf_counter() - validation_started) * 1000, 2)
failed_export = any(item["status"] != "created" for item in artifacts)
critique_blocked = any(
issue.severity == "error" or (strict_critique and issue.severity != "info")
for issue in critique_issues
)
validation_blocked = (
not validation.ok
or not parity["ok"]
or score["score"] < float(min_score)
or critique_blocked
)
if failed_export:
status = "failed_export"
elif validation_blocked:
status = "failed_validation"
else:
status = "success"
validation_payload = {
"validator": validation.to_dict(),
"critique": [issue.to_dict() for issue in critique_issues],
"critique_summary": critique_summary,
"score": score,
"parity": parity,
}
validation_path = destination / "validation.json"
validation_path.write_text(
json.dumps(validation_payload, ensure_ascii=False, indent=2, sort_keys=True),
encoding="utf-8",
)
plan = backend.get_plan_spec()
timings["total_ms"] = round((time.perf_counter() - started) * 1000, 2)
manifest = {
"schema_version": "1.0",
"status": status,
"version": __version__,
"git_sha": _git_sha(),
"generated_at": datetime.now(UTC).isoformat(),
"backend": backend.name,
"capabilities": capabilities,
"spec_hash": plan.get("spec_hash") if plan else None,
"requested_formats": requested,
"min_score": float(min_score),
"strict_critique": strict_critique,
"score": score,
"validator": validation.to_dict(),
"critique": validation_payload["critique"],
"critique_summary": critique_summary,
"parity": parity,
"artifacts": artifacts,
"validation_artifact": _artifact_record("json", validation_path, status="created"),
"timings_ms": timings,
}
manifest_path = destination / "manifest.json"
manifest_path.write_text(
json.dumps(manifest, ensure_ascii=False, indent=2, sort_keys=True),
encoding="utf-8",
)
return DeliveryResult(
status=status,
output_dir=str(destination),
manifest_path=str(manifest_path),
artifacts=artifacts,
score=score,
parity=parity,
)
+6
View File
@@ -50,6 +50,9 @@ class PlanSpec:
view_count: int = 1
dim_style: DimStyle = "chain"
notes: list[str] = field(default_factory=list)
requirements: dict = field(default_factory=dict)
spec_hash: str | None = None
preflight_status: Literal["ready", "skipped"] = "skipped"
def to_dict(self) -> dict:
return {
@@ -60,6 +63,9 @@ class PlanSpec:
"view_count": self.view_count,
"dim_style": self.dim_style,
"notes": list(self.notes),
"requirements": dict(self.requirements),
"spec_hash": self.spec_hash,
"preflight_status": self.preflight_status,
}
+218
View File
@@ -0,0 +1,218 @@
"""Pure, deterministic drawing-requirement preflight validation."""
from __future__ import annotations
import hashlib
import json
import math
from dataclasses import dataclass, field
from numbers import Real
from typing import Any
@dataclass(frozen=True)
class PreflightQuestion:
code: str
field: str
question: str
choices: list[str] = field(default_factory=list)
def to_dict(self) -> dict:
return {
"code": self.code,
"field": self.field,
"question": self.question,
"choices": list(self.choices),
}
@dataclass(frozen=True)
class PreflightConflict:
code: str
field: str
values: list[Any]
message: str
def to_dict(self) -> dict:
return {
"code": self.code,
"field": self.field,
"values": list(self.values),
"message": self.message,
}
@dataclass
class PreflightResult:
ready: bool
normalized_spec: dict
questions: list[PreflightQuestion]
conflicts: list[PreflightConflict]
assumptions: list[dict]
spec_hash: str
def to_dict(self) -> dict:
return {
"ready": self.ready,
"normalized_spec": self.normalized_spec,
"questions": [item.to_dict() for item in self.questions],
"conflicts": [item.to_dict() for item in self.conflicts],
"assumptions": list(self.assumptions),
"spec_hash": self.spec_hash,
}
def _normalize(value: Any) -> Any:
if isinstance(value, dict):
return {str(key).strip(): _normalize(item) for key, item in sorted(value.items())}
if isinstance(value, list):
return [_normalize(item) for item in value]
if isinstance(value, str):
return value.strip()
if isinstance(value, Real) and not isinstance(value, bool):
return float(value)
return value
def _spec_hash(spec: dict) -> str:
encoded = json.dumps(spec, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
return "sha256:" + hashlib.sha256(encoded.encode("utf-8")).hexdigest()
def preflight_drawing(
intent: str,
requirements: dict | None = None,
sheet_size: str = "A3",
scale: float = 1.0,
layer_set_id: str = "mech",
view_count: int = 1,
dim_style: str = "chain",
*,
allow_assumptions: bool = False,
) -> PreflightResult:
"""Normalize a drawing request and surface missing or conflicting facts."""
req = _normalize(dict(requirements or {}))
assumptions: list[dict] = []
if allow_assumptions and not req.get("units"):
req["units"] = "mm"
assumptions.append({"field": "units", "value": "mm", "reason": "default"})
if allow_assumptions and not req.get("tolerance_policy"):
req["tolerance_policy"] = "general"
assumptions.append(
{"field": "tolerance_policy", "value": "general", "reason": "default"}
)
if isinstance(req.get("units"), str):
req["units"] = req["units"].lower()
if isinstance(req.get("part_type"), str):
req["part_type"] = req["part_type"].lower()
questions: list[PreflightQuestion] = []
required = (
("units", "MISSING_UNITS", "Çizim birimi nedir?", ["mm", "inch"]),
("part_type", "MISSING_PART_TYPE", "Üretilecek parça/çizim türü nedir?", []),
("dimensions", "MISSING_DIMENSIONS", "Ana üretim ölçüleri nelerdir?", []),
(
"tolerance_policy",
"MISSING_TOLERANCE_POLICY",
"Hangi tolerans politikası kullanılmalı?",
["ISO 2768-m", "explicit", "none"],
),
)
for field_name, code, question, choices in required:
value = req.get(field_name)
if value is None or value == "" or value == {} or value == []:
questions.append(PreflightQuestion(code, field_name, question, choices))
conflicts: list[PreflightConflict] = []
seen_constraints: dict[str, Any] = {}
for constraint in req.get("constraints", []) or []:
if not isinstance(constraint, dict):
continue
field_name = str(constraint.get("field", "")).strip()
if not field_name or "value" not in constraint:
continue
value = _normalize(constraint["value"])
if field_name in seen_constraints and seen_constraints[field_name] != value:
conflicts.append(
PreflightConflict(
"CONFLICTING_REQUIREMENT",
field_name,
[seen_constraints[field_name], value],
f"{field_name} için birbiriyle çelişen değerler verildi.",
)
)
else:
seen_constraints[field_name] = value
normalized_sheet = str(sheet_size).strip().upper()
normalized_layer_set = str(layer_set_id).strip().lower()
normalized_dim_style = str(dim_style).strip().lower()
try:
normalized_scale = float(scale)
except (TypeError, ValueError):
normalized_scale = float("nan")
try:
normalized_view_count = int(view_count)
except (TypeError, ValueError):
normalized_view_count = 0
parameter_rules = (
(
normalized_sheet in {"A4", "A3", "A2", "A1", "A0"},
"INVALID_SHEET_SIZE",
"sheet_size",
sheet_size,
"sheet_size must be one of A4, A3, A2, A1, A0.",
),
(
math.isfinite(normalized_scale) and normalized_scale > 0,
"INVALID_SCALE",
"scale",
scale,
"scale must be a finite number greater than zero.",
),
(
1 <= normalized_view_count <= 20,
"INVALID_VIEW_COUNT",
"view_count",
view_count,
"view_count must be between 1 and 20.",
),
(
normalized_dim_style in {"chain", "baseline", "ordinate", "mixed"},
"INVALID_DIM_STYLE",
"dim_style",
dim_style,
"dim_style must be chain, baseline, ordinate, or mixed.",
),
(
normalized_layer_set in {"iso13567", "mech", "pid"},
"INVALID_LAYER_SET",
"layer_set_id",
layer_set_id,
"layer_set_id must be iso13567, mech, or pid.",
),
)
for valid, code, field_name, value, message in parameter_rules:
if not valid:
conflicts.append(PreflightConflict(code, field_name, [value], message))
normalized_spec = {
"intent": str(intent).strip(),
"requirements": req,
"sheet_size": normalized_sheet,
"scale": normalized_scale if math.isfinite(normalized_scale) else str(scale).strip(),
"layer_set_id": normalized_layer_set,
"view_count": normalized_view_count,
"dim_style": normalized_dim_style,
}
return PreflightResult(
ready=not questions and not conflicts,
normalized_spec=normalized_spec,
questions=questions,
conflicts=conflicts,
assumptions=assumptions,
spec_hash=_spec_hash(normalized_spec),
)
+271
View File
@@ -0,0 +1,271 @@
"""Bounded critique -> repair -> re-critique orchestration."""
from __future__ import annotations
import hashlib
import json
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
from typing import TYPE_CHECKING
from .plan_spec import Issue
from .repairs import (
repair_construction_left,
repair_dim_overlap,
repair_duplicate_entities,
repair_iso128,
repair_layer_color,
repair_untrimmed_corner,
)
from .scoring import score_findings
if TYPE_CHECKING:
from backends.base import AutoCADBackend
RepairHandler = Callable[["AutoCADBackend", Issue], Awaitable[dict]]
@dataclass
class RepairAction:
focus: str
fingerprint: str
handles: list[str]
status: str
detail: dict = field(default_factory=dict)
def to_dict(self) -> dict:
return {
"focus": self.focus,
"fingerprint": self.fingerprint,
"handles": list(self.handles),
"status": self.status,
"detail": self.detail,
}
@dataclass
class RefineRound:
number: int
score_before: float
score_after: float
error_count_before: int
error_count_after: int
transaction: str
actions: list[RepairAction]
def to_dict(self) -> dict:
return {
"number": self.number,
"score_before": self.score_before,
"score_after": self.score_after,
"error_count_before": self.error_count_before,
"error_count_after": self.error_count_after,
"transaction": self.transaction,
"actions": [action.to_dict() for action in self.actions],
}
@dataclass
class RefineResult:
status: str
initial_score: float
final_score: float
rounds: list[RefineRound]
remaining_issues: list[dict]
def to_dict(self) -> dict:
return {
"status": self.status,
"initial_score": self.initial_score,
"final_score": self.final_score,
"rounds": [item.to_dict() for item in self.rounds],
"remaining_issues": list(self.remaining_issues),
}
class RepairRegistry:
def __init__(self, *, defaults: bool = False):
self._handlers: dict[str, RepairHandler] = {}
if defaults:
self.register("construction_left", repair_construction_left)
self.register("duplicate_entities", repair_duplicate_entities)
self.register("layer_color", repair_layer_color)
self.register("iso128", repair_iso128)
self.register("untrimmed_corner", repair_untrimmed_corner)
self.register("dim_overlap", repair_dim_overlap)
def register(self, focus: str, handler: RepairHandler) -> None:
self._handlers[focus] = handler
def get(self, focus: str) -> RepairHandler | None:
return self._handlers.get(focus)
def issue_fingerprint(issue: Issue) -> str:
payload = {
"focus": issue.focus,
"handles": sorted(issue.handles),
"detail": issue.detail,
}
encoded = json.dumps(payload, sort_keys=True, separators=(",", ":"), default=str)
return hashlib.sha256(encoded.encode("utf-8")).hexdigest()
def _metrics(issues: list[Issue]) -> tuple[float, int]:
errors = sum(issue.severity == "error" for issue in issues)
warnings = sum(issue.severity == "warning" for issue in issues)
info = sum(issue.severity == "info" for issue in issues)
return float(score_findings(errors, warnings, info)["score"]), errors
async def refine_drawing(
backend: AutoCADBackend,
max_rounds: int = 3,
min_score: float = 95.0,
focus: list[str] | None = None,
allowed_repairs: list[str] | None = None,
dry_run: bool = False,
*,
registry: RepairRegistry | None = None,
) -> RefineResult:
"""Apply deterministic repairs without allowing quality to regress."""
max_rounds = max(1, min(3, int(max_rounds)))
min_score = max(0.0, min(100.0, float(min_score)))
registry = registry or RepairRegistry(defaults=True)
allowed = set(allowed_repairs) if allowed_repairs is not None else None
issues = await backend.drawing_critique(focus)
initial_score, _initial_errors = _metrics(issues)
rounds: list[RefineRound] = []
if initial_score >= min_score:
return RefineResult(
"threshold_met", initial_score, initial_score, [], [item.to_dict() for item in issues]
)
if dry_run:
actions = []
for issue in issues:
handler = registry.get(issue.focus)
permitted = allowed is None or issue.focus in allowed
actions.append(
RepairAction(
issue.focus,
issue_fingerprint(issue),
list(issue.handles),
"planned" if handler is not None and permitted else "manual_required",
)
)
score, errors = _metrics(issues)
rounds.append(RefineRound(1, score, score, errors, errors, "not_started", actions))
return RefineResult("dry_run", initial_score, score, rounds, [i.to_dict() for i in issues])
status = "max_rounds"
for number in range(1, max_rounds + 1):
score_before, errors_before = _metrics(issues)
candidates: list[tuple[Issue, RepairHandler]] = []
for issue in issues:
handler = registry.get(issue.focus)
if handler is not None and (allowed is None or issue.focus in allowed):
candidates.append((issue, handler))
if not candidates:
status = "manual_required"
break
transaction = await backend.transaction_begin()
actions: list[RepairAction] = []
if not isinstance(transaction, dict) or not transaction.get("ok"):
detail = transaction if isinstance(transaction, dict) else {"result": transaction}
actions.append(RepairAction("internal", "", [], "failed", detail))
rounds.append(
RefineRound(
number,
score_before,
score_before,
errors_before,
errors_before,
"not_started",
actions,
)
)
status = "transaction_unavailable"
break
try:
for issue, handler in candidates:
detail = await handler(backend, issue)
actions.append(
RepairAction(
issue.focus,
issue_fingerprint(issue),
list(issue.handles),
"applied",
detail,
)
)
after = await backend.drawing_critique(focus)
score_after, errors_after = _metrics(after)
except Exception as exc:
await backend.transaction_rollback()
actions.append(RepairAction("internal", "", [], "failed", {"error": str(exc)}))
rounds.append(
RefineRound(
number,
score_before,
score_before,
errors_before,
errors_before,
"rolled_back",
actions,
)
)
status = "repair_failed"
issues = await backend.drawing_critique(focus)
break
if score_after < score_before or errors_after > errors_before:
await backend.transaction_rollback()
rounds.append(
RefineRound(
number,
score_before,
score_before,
errors_before,
errors_before,
"rolled_back",
actions,
)
)
status = "rolled_back"
issues = await backend.drawing_critique(focus)
break
await backend.transaction_commit()
rounds.append(
RefineRound(
number,
score_before,
score_after,
errors_before,
errors_after,
"committed",
actions,
)
)
before_fingerprints = {issue_fingerprint(item) for item in issues}
after_fingerprints = {issue_fingerprint(item) for item in after}
issues = after
if score_after >= min_score:
status = "threshold_met"
break
if after_fingerprints == before_fingerprints:
status = "no_progress"
break
final_score, _final_errors = _metrics(issues)
return RefineResult(
status,
initial_score,
final_score,
rounds,
[item.to_dict() for item in issues],
)
+81
View File
@@ -0,0 +1,81 @@
"""Deterministic repair handlers for the bounded drawing refiner."""
from __future__ import annotations
import math
from typing import TYPE_CHECKING
from .plan_spec import ISO_128_LINEWEIGHTS_MM, Issue
if TYPE_CHECKING:
from backends.base import AutoCADBackend
async def repair_construction_left(backend: AutoCADBackend, issue: Issue) -> dict:
deleted = []
for handle in issue.handles:
await backend.entity_delete(handle)
deleted.append(handle)
return {"deleted_handles": deleted}
async def repair_duplicate_entities(backend: AutoCADBackend, issue: Issue) -> dict:
if len(issue.handles) < 2:
raise RuntimeError("duplicate_entities repair requires two handles")
handle = issue.handles[-1]
await backend.entity_delete(handle)
return {"deleted_handle": handle, "kept_handle": issue.handles[0]}
async def repair_layer_color(backend: AutoCADBackend, issue: Issue) -> dict:
layer = str(issue.detail["layer"])
expected = int(issue.detail["expected"])
await backend.layer_modify(layer, color=expected)
return {"layer": layer, "color": expected}
async def repair_iso128(backend: AutoCADBackend, issue: Issue) -> dict:
layer = str(issue.detail["layer"])
raw = float(issue.detail["lineweight"])
millimetres = raw / 100.0 if raw > 2.05 else raw
nearest = min(ISO_128_LINEWEIGHTS_MM, key=lambda item: abs(item - millimetres))
await backend.layer_modify(layer, lineweight=nearest)
return {"layer": layer, "lineweight_mm": nearest}
def _endpoint_kwargs(entity, old_point: list[float], new_point: tuple[float, float]) -> dict:
start = entity.properties.get("start")
end = entity.properties.get("end")
if not start or not end:
raise RuntimeError(f"LINE {entity.handle} is missing endpoints")
old = (float(old_point[0]), float(old_point[1]))
if math.dist((float(start[0]), float(start[1])), old) <= math.dist(
(float(end[0]), float(end[1])), old
):
return {"x1": new_point[0], "y1": new_point[1]}
return {"x2": new_point[0], "y2": new_point[1]}
async def repair_untrimmed_corner(backend: AutoCADBackend, issue: Issue) -> dict:
if len(issue.handles) != 2 or "p1" not in issue.detail or "p2" not in issue.detail:
raise RuntimeError("untrimmed_corner repair requires two handles and endpoint details")
p1 = issue.detail["p1"]
p2 = issue.detail["p2"]
midpoint = ((float(p1[0]) + float(p2[0])) / 2, (float(p1[1]) + float(p2[1])) / 2)
first = await backend.entity_get(issue.handles[0])
second = await backend.entity_get(issue.handles[1])
await backend.entity_edit_geometry(
issue.handles[0], **_endpoint_kwargs(first, p1, midpoint)
)
await backend.entity_edit_geometry(
issue.handles[1], **_endpoint_kwargs(second, p2, midpoint)
)
return {"handles": list(issue.handles), "corner": list(midpoint)}
async def repair_dim_overlap(backend: AutoCADBackend, issue: Issue) -> dict:
if len(issue.handles) < 2:
raise RuntimeError("dim_overlap repair requires two handles")
handle = issue.handles[-1]
await backend.entity_move(handle, 0.0, 6.0)
return {"moved_handle": handle, "dy": 6.0}