Files
T
Umut CANandClaude Opus 5.5 6e004cc658 feat(arch): arch_plan_from_spec, three arch_* critique focuses, the arch pack, lean 64 and the counts
The three track-F branches are merged; this is the integration that makes the
twelve SECTION 25 tools one surface.

- engineering/arch/spec.py: a whole plan from one document - walls with their
  openings, stairs, room labels measured off the walls just drawn, the
  exterior chains and the schedules - inside one transaction. Everything the
  model, the wall engine or the stair symbol can refuse is refused before the
  transaction opens, by path; a room point in no closed face rolls it back,
  and so does a cancelled request, under the anyio shield of pid/spec.py.
  EXAMPLE_SPEC's rooms measure 4825 x 5750 and 3825 x 5750 - the net floor,
  computed by hand in the tests.
- engineering/arch/critique.py: arch_room_unlabelled, arch_wall_gap (50 mm,
  one issue per pair of walls, measured to the wall body so an L corner and a
  T stem both read as joined) and arch_opening_clash. One index per critique
  run; a drawing without an ACADMCP_ARCH record yields nothing. Each focus
  has a positive and a negative fixture.
- TOOL_PACK_NAMES gains "arch" (the twelve SECTION 25 tools); lean gains
  arch_wall, arch_opening, arch_room and arch_plan_from_spec and reaches 64;
  the arch tag ranks after mech, so SECTION 25 files as one `architecture`
  group - 241 tools in 26 groups.
- Six golden tuning cases, two of them Turkish, the arch_plan_from_spec alias
  record, the shared-command allowlist reconciled with what the merged records
  claim, the inventory regenerated, the registry snapshot retaken, and the
  README figures taken from check_doc_numbers rather than typed.
- Two more Turkish golden cases close the reconciliation's alias gaps
  ("vaziyet planı çiz" -> arch_plan_from_spec, "oda alanlarını hesapla" ->
  arch_rooms_detect); the arch records carry vaziyet / alan / oda / kat planı
  in diacritic and ASCII-folded spellings, because the search tokenizer splits
  on a non-ASCII letter. BOUNDARY and AREA stay with boundary_trace and
  analysis_measure_entity on purpose, recorded in the arch alias comment.
  Tuning top-1 73/84, top-3 83/84; holdout top-3 15/15.
- tests/test_token_suite.py: TOOL_PACKS=core,mech now drops the arch pack
  too (measured 193 = 236 - 9 - 22 - 12).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-23 23:20:05 +03:00

381 lines
14 KiB
Python

"""``arch_plan_from_spec``: a whole plan from one declarative document, in one transaction.
Walls with their hosted openings, stairs, room labels (each area measured off
the walls just drawn), the exterior dimension chains and the schedules — in
that order, because each step reads what the one before it drew: a room label
measures the wall faces, and a schedule reads the ``ACADMCP_ARCH`` records.
Everything that can be refused without a drawing is refused before the
transaction opens, by path (``walls[2].thickness``, ``openings[1]: ...``): the
model, the openings against their walls, the junctions (the wall engine is
pure and is run once in memory for exactly this), every stair's plan symbol,
the rooms (a typed ``area`` included), the chain sides and the schedule kinds.
What only the drawing can refuse - a room point that lies in no closed face -
refuses inside the transaction, and the transaction is rolled back.
The rollback is shielded for the reason ``engineering/pid/spec.py`` documents
at length: the MCP SDK cancels a request by cancelling an anyio scope, anyio
cancellation is level-triggered, and a bare ``await`` in the handler would
itself be cancelled before the rollback body ran - leaving half a plan inside
an open transaction.
"""
from __future__ import annotations
import logging
import math
from typing import TYPE_CHECKING
import anyio
from engineering.arch import draw as _draw
from engineering.arch import rooms as _rooms
from engineering.arch import schedule as _schedule
from .lang import LANGS
from .model import (
opening_from_dict,
room_from_dict,
stair_from_dict,
validate_openings,
wall_from_dict,
)
if TYPE_CHECKING:
from backends.base import AutoCADBackend
log = logging.getLogger(__name__)
SPEC_KEYS = (
"sheet",
"lang",
"poche",
"walls",
"openings",
"stairs",
"rooms",
"chains",
"schedules",
)
SHEET_KEYS = ("intent", "sheet_size", "scale")
CHAIN_SIDES = ("bottom", "top", "left", "right")
SCHEDULE_KINDS = ("doors", "windows", "rooms")
#: A two-room plan the tool docstring and the tests use: an entrance door and
#: an interior door, two windows, a straight stair, both rooms labelled, the
#: two exterior chains and all three schedules. The room numbers are "01" /
#: "02", not "R1": the finalize validator reads R-and-a-digit as a radius
#: callout typed as text.
EXAMPLE_SPEC: dict = {
"sheet": {"intent": "Two-room ground floor plan", "sheet_size": "A3", "scale": 50},
"lang": "en",
"walls": [
{
"id": "EXT",
"axis": [[0, 0], [9000, 0], [9000, 6000], [0, 6000]],
"thickness": 250,
"closed": True,
"material": "brick",
},
{"id": "INT", "axis": [[5000, 0], [5000, 6000]], "thickness": 100, "material": "aac"},
],
"openings": [
{"id": "D1", "wall": "EXT", "kind": "door", "offset": 1000, "width": 1000, "tag": "D1"},
{
"id": "D2",
"wall": "INT",
"kind": "door",
"offset": 2000,
"width": 900,
"hand": "right",
"tag": "D2",
},
{
"id": "W1",
"wall": "EXT",
"kind": "window",
"offset": 20500,
"width": 1500,
"sill": 900,
"height": 1200,
"tag": "W1",
},
{
"id": "W2",
"wall": "EXT",
"kind": "window",
"offset": 11000,
"width": 1200,
"sill": 900,
"height": 1200,
"tag": "W2",
},
],
"stairs": [
{
"id": "S1",
"start": [6000, 1000],
"direction_deg": 90,
"width": 900,
"risers": 16,
"riser_height": 175,
"going": 280,
}
],
"rooms": [
{"id": "R1", "name": "LIVING", "number": "01", "at": [2500, 3000]},
{"id": "R2", "name": "HALL", "number": "02", "at": [7900, 3000]},
],
"chains": {"sides": ["bottom", "left"]},
"schedules": [
{"kind": "doors", "at": [10500, 6000]},
{"kind": "windows", "at": [10500, 4000]},
{"kind": "rooms", "at": [10500, 2000]},
],
}
def _number(value, where: str) -> float:
if isinstance(value, bool):
raise ValueError(f"{where}: expected a number, got {value!r}")
try:
number = float(value)
except (TypeError, ValueError):
raise ValueError(f"{where}: expected a number, got {value!r}") from None
if not math.isfinite(number) or number <= 0.0:
raise ValueError(f"{where}: must be a finite number greater than zero, got {value!r}")
return number
def _point(value, where: str) -> tuple[float, float]:
if isinstance(value, (str, bytes)) or not isinstance(value, (list, tuple)) or len(value) != 2:
raise ValueError(f"{where}: expected [x, y], got {value!r}")
try:
x, y = float(value[0]), float(value[1])
except (TypeError, ValueError):
raise ValueError(f"{where}: expected [x, y], got {value!r}") from None
if not (math.isfinite(x) and math.isfinite(y)):
raise ValueError(f"{where}: must be two finite numbers, got {value!r}")
return (x, y)
def _list(spec: dict, key: str) -> list:
value = spec.get(key)
if value is None:
return []
if isinstance(value, (str, bytes, dict)) or not isinstance(value, (list, tuple)):
raise ValueError(f"{key}: expected a list, got {type(value).__name__}")
return list(value)
def validate_spec(spec: dict) -> dict:
"""The whole document checked and normalised - nothing is drawn here.
Returns ``{"sheet", "lang", "scale", "poche", "walls", "openings",
"stairs", "rooms", "chains", "schedules"}`` with model objects in place of
dicts (rooms stay dicts: ``label_room`` takes the request, not a Room).
"""
from engineering.arch.stairs import stair_prims
from engineering.arch.walls import wall_geometry
if not isinstance(spec, dict):
raise ValueError(f"spec: expected a dict, got {type(spec).__name__}")
unknown = sorted(set(spec) - set(SPEC_KEYS))
if unknown:
raise ValueError(
f"spec: unknown key(s) {', '.join(unknown)}; keys are {', '.join(SPEC_KEYS)}"
)
sheet = spec.get("sheet") or {}
if not isinstance(sheet, dict):
raise ValueError(f"sheet: expected a dict, got {type(sheet).__name__}")
extra = sorted(set(sheet) - set(SHEET_KEYS))
if extra:
raise ValueError(
f"sheet: unknown key(s) {', '.join(extra)}; keys are {', '.join(SHEET_KEYS)}"
)
scale = _number(sheet.get("scale", 50), "sheet.scale")
lang = spec.get("lang", "en")
if lang not in LANGS:
raise ValueError(f"lang: unknown language {lang!r}; languages are {', '.join(LANGS)}")
poche = spec.get("poche", True)
if not isinstance(poche, bool):
raise ValueError(f"poche: expected true or false, got {poche!r}")
raw_walls = _list(spec, "walls")
if not raw_walls:
raise ValueError("walls: name at least one wall - a plan without walls has no rooms")
walls = [wall_from_dict(item, f"walls[{i}]") for i, item in enumerate(raw_walls)]
openings = [
opening_from_dict(item, f"openings[{i}]") for i, item in enumerate(_list(spec, "openings"))
]
try:
validate_openings(walls, openings)
geometry = wall_geometry(walls, openings, poche=poche)
except ValueError as exc:
raise ValueError(f"walls/openings: {exc}") from exc
stairs = []
for i, item in enumerate(_list(spec, "stairs")):
stair = stair_from_dict(item, f"stairs[{i}]")
try:
stair_prims(stair, lang=lang, scale=scale)
except ValueError as exc:
raise ValueError(f"stairs[{i}]: {exc}") from exc
stairs.append(stair)
rooms = []
seen_rooms: set[str] = set()
for i, item in enumerate(_list(spec, "rooms")):
room = room_from_dict(item, f"rooms[{i}]")
if room.id in seen_rooms:
raise ValueError(f"rooms[{i}].id: {room.id!r} is used twice")
seen_rooms.add(room.id)
rooms.append(dict(item))
chains = spec.get("chains")
sides: tuple[str, ...] | None = None
if chains not in (None, False):
if chains is True:
chains = {}
if not isinstance(chains, dict) or set(chains) - {"sides"}:
raise ValueError(f"chains: expected {{sides: [...]}}, got {chains!r}")
sides = tuple(chains.get("sides") or ("bottom", "left"))
bad = [side for side in sides if side not in CHAIN_SIDES]
if bad or not sides:
raise ValueError(f"chains.sides: {bad or sides!r} - sides are {', '.join(CHAIN_SIDES)}")
schedules = []
for i, item in enumerate(_list(spec, "schedules")):
if not isinstance(item, dict) or set(item) - {"kind", "at"}:
raise ValueError(f"schedules[{i}]: expected {{kind, at}}, got {item!r}")
kind = item.get("kind")
if kind not in SCHEDULE_KINDS:
raise ValueError(
f"schedules[{i}].kind: {kind!r} is not a schedule; "
f"schedules are {', '.join(SCHEDULE_KINDS)}"
)
schedules.append({"kind": kind, "at": _point(item.get("at"), f"schedules[{i}].at")})
return {
"sheet": {
"intent": str(sheet.get("intent") or "architectural plan"),
"sheet_size": str(sheet.get("sheet_size") or "A3"),
},
"scale": scale,
"lang": lang,
"poche": poche,
"walls": walls,
"openings": openings,
"omitted": list(geometry.omitted),
"stairs": stairs,
"rooms": rooms,
"chains": sides,
"schedules": schedules,
}
async def draw_plan_from_spec(backend: AutoCADBackend, spec: dict) -> dict:
"""Validate, then draw the whole plan inside one transaction.
Returns ``{"walls", "openings", "stairs", "rooms", "chains", "schedules",
"omitted", "drawn", "critique", "backend"}``: the wall and opening ids that
were drawn (an opening the wall engine could not cut is in ``omitted``, not
in ``openings``), each drawing step's own result (``drawn`` is
``draw_walls``'), and the three ``arch_*`` critique focuses run over the
committed drawing.
"""
from engineering.arch.critique import ARCH_FOCUSES, build_index, issues_for
norm = validate_spec(spec)
scale = norm["scale"]
lang = norm["lang"]
if backend.get_plan_spec() is None:
await backend.drawing_plan(
norm["sheet"]["intent"], norm["sheet"]["sheet_size"], 1.0 / scale, "arch"
)
begun = await backend.transaction_begin()
if not isinstance(begun, dict) or not begun.get("ok"):
# a refusal, not a crash: nothing has been drawn yet
detail = begun.get("error") if isinstance(begun, dict) else begun
raise ValueError(
f"arch_plan_from_spec: could not open a transaction ({detail}); nothing was drawn. "
"If one is already open, close it with transaction_commit or "
"transaction_rollback and retry."
)
result: dict = {
"walls": None,
"stairs": [],
"rooms": [],
"chains": None,
"schedules": [],
}
try:
await backend.drawing_apply_iso_layers("arch")
result["walls"] = await _draw.draw_walls(
backend, norm["walls"], norm["openings"], poche=norm["poche"]
)
for i, stair in enumerate(norm["stairs"]):
try:
result["stairs"].append(
await _draw.draw_stair(backend, stair, lang=lang, scale=scale)
)
except ValueError as exc:
raise ValueError(f"stairs[{i}]: {exc}") from exc
for i, room in enumerate(norm["rooms"]):
try:
result["rooms"].append(
await _rooms.label_room(backend, room, lang=lang, scale=scale)
)
except ValueError as exc:
raise ValueError(f"rooms[{i}]: {exc}") from exc
if norm["chains"] is not None:
result["chains"] = await _draw.draw_chains(
backend, norm["walls"], norm["openings"], sides=norm["chains"], scale=scale
)
for i, item in enumerate(norm["schedules"]):
try:
result["schedules"].append(
await _schedule.draw_schedule(
backend, item["kind"], at=item["at"], lang=lang, scale=scale
)
)
except ValueError as exc:
raise ValueError(f"schedules[{i}]: {exc}") from exc
except BaseException as exc:
cancelled = isinstance(exc, anyio.get_cancelled_exc_class())
with anyio.CancelScope(shield=True):
try:
await backend.transaction_rollback()
except Exception as rollback_exc:
if not cancelled:
raise
log.error(
"arch_plan_from_spec: rollback after a cancelled request failed (%s: %s); "
"the plan may be half-drawn - check system_status and "
"transaction_rollback before retrying",
type(rollback_exc).__name__,
rollback_exc,
)
raise
await backend.transaction_commit()
index = await build_index(backend)
critique = [issue.to_dict() for focus in ARCH_FOCUSES for issue in issues_for(focus, index)]
drawn = result["walls"] or {}
omitted = list(drawn.get("omitted", norm["omitted"]))
skipped = {str(item.get("element")) for item in omitted}
return {
"walls": [wall.id for wall in norm["walls"]],
"openings": [o.id for o in norm["openings"] if o.id not in skipped],
"stairs": result["stairs"],
"rooms": result["rooms"],
"chains": result["chains"],
"schedules": result["schedules"],
"omitted": omitted,
"drawn": drawn,
"critique": critique,
"backend": backend.name,
}