release: prepare v1.3.0
This commit is contained in:
@@ -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}
|
||||
Reference in New Issue
Block a user