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
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
ad5a32a34a
commit
6e004cc658
@@ -0,0 +1,380 @@
|
||||
"""``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,
|
||||
}
|
||||
Reference in New Issue
Block a user