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
163 lines
5.8 KiB
Python
163 lines
5.8 KiB
Python
"""``arch_plan_from_spec``: validation by path, one transaction, the shielded rollback.
|
|
|
|
Headless ezdxf against a real document. The areas are computed by hand from
|
|
``EXAMPLE_SPEC``: the exterior ring runs on axis (0,0)-(9000,6000) at 250 mm,
|
|
so its inner faces are x = 125 / 8875 and y = 125 / 5875; the interior wall
|
|
runs on x = 5000 at 100 mm, faces x = 4950 / 5050. Room 01 is therefore
|
|
(4950 - 125) x (5875 - 125) = 4825 x 5750 = 27 743 750 mm², room 02
|
|
(8875 - 5050) x 5750 = 3825 x 5750 = 21 993 750 mm² - the net floor, not the
|
|
axis area.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import copy
|
|
|
|
import anyio
|
|
import pytest
|
|
|
|
from engineering.arch.spec import EXAMPLE_SPEC, draw_plan_from_spec, validate_spec
|
|
|
|
ROOM_01 = 4825.0 * 5750.0
|
|
ROOM_02 = 3825.0 * 5750.0
|
|
|
|
|
|
def _spec(**over) -> dict:
|
|
spec = copy.deepcopy(EXAMPLE_SPEC)
|
|
spec.update(over)
|
|
return spec
|
|
|
|
|
|
async def _count(backend) -> int:
|
|
return len(await backend.entity_list(limit=5000))
|
|
|
|
|
|
# -- validation, before anything is drawn ---------------------------------------
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("over", "message"),
|
|
[
|
|
({"colour": "red"}, r"^spec: unknown key\(s\) colour; keys are sheet"),
|
|
({"walls": []}, r"^walls: name at least one wall"),
|
|
({"lang": "de"}, r"^lang: unknown language 'de'; languages are en, tr"),
|
|
({"sheet": {"scale": 0}}, r"^sheet\.scale: must be a finite number greater than zero"),
|
|
({"chains": {"sides": ["north"]}}, r"^chains\.sides: \['north'\]"),
|
|
(
|
|
{"schedules": [{"kind": "stairs", "at": [0, 0]}]},
|
|
r"^schedules\[0\]\.kind: 'stairs' is not a schedule",
|
|
),
|
|
],
|
|
)
|
|
@pytest.mark.asyncio
|
|
async def test_the_document_is_refused_by_path(backend, over, message):
|
|
with pytest.raises(ValueError, match=message):
|
|
await draw_plan_from_spec(backend, _spec(**over))
|
|
assert await _count(backend) == 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_a_bad_wall_is_named_by_its_index(backend):
|
|
spec = _spec()
|
|
spec["walls"][1]["thickness"] = -100
|
|
with pytest.raises(ValueError, match=r"^walls\[1\]\.thickness: must be greater than zero"):
|
|
await draw_plan_from_spec(backend, spec)
|
|
assert await _count(backend) == 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_two_overlapping_openings_are_refused_naming_both(backend):
|
|
spec = _spec()
|
|
spec["openings"][0]["offset"] = 1000
|
|
spec["openings"].append(
|
|
{"id": "D9", "wall": "EXT", "kind": "door", "offset": 1500, "width": 900}
|
|
)
|
|
with pytest.raises(ValueError, match=r"openings D1 and D9 overlap on wall EXT"):
|
|
await draw_plan_from_spec(backend, spec)
|
|
assert await _count(backend) == 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_a_typed_room_area_is_refused(backend):
|
|
spec = _spec()
|
|
spec["rooms"][0]["area"] = 27.0e6
|
|
with pytest.raises(ValueError, match=r"^rooms\[0\]\.area: an area is measured"):
|
|
await draw_plan_from_spec(backend, spec)
|
|
assert await _count(backend) == 0
|
|
|
|
|
|
def test_validate_spec_turns_dicts_into_the_model():
|
|
norm = validate_spec(EXAMPLE_SPEC)
|
|
assert [w.id for w in norm["walls"]] == ["EXT", "INT"]
|
|
assert [o.id for o in norm["openings"]] == ["D1", "D2", "W1", "W2"]
|
|
assert norm["scale"] == 50.0 and norm["lang"] == "en"
|
|
assert norm["chains"] == ("bottom", "left")
|
|
assert [s["kind"] for s in norm["schedules"]] == ["doors", "windows", "rooms"]
|
|
|
|
|
|
# -- the whole plan --------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_the_example_plan_draws_in_one_transaction_and_measures_its_rooms(backend):
|
|
from engineering.arch.draw import read_plan
|
|
|
|
result = await draw_plan_from_spec(backend, EXAMPLE_SPEC)
|
|
assert result["walls"] == ["EXT", "INT"]
|
|
assert result["openings"] == ["D1", "D2", "W1", "W2"]
|
|
assert result["omitted"] == []
|
|
assert len(result["rooms"]) == 2 and len(result["schedules"]) == 3
|
|
assert result["critique"] == []
|
|
plan = await read_plan(backend)
|
|
areas = {room.number: room.area for room in plan["rooms"]}
|
|
assert areas["01"] == pytest.approx(ROOM_01, abs=0.5)
|
|
assert areas["02"] == pytest.approx(ROOM_02, abs=0.5)
|
|
assert backend.get_plan_spec()["layer_set_id"] == "arch"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_a_room_outside_every_face_rolls_the_whole_plan_back(backend):
|
|
spec = _spec()
|
|
spec["rooms"][1]["at"] = [20000, 20000]
|
|
with pytest.raises(ValueError, match=r"^rooms\[1\]: "):
|
|
await draw_plan_from_spec(backend, spec)
|
|
assert await _count(backend) == 0
|
|
begun = await backend.transaction_begin()
|
|
assert begun["ok"] is True, "the rollback must have closed the transaction"
|
|
await backend.transaction_rollback()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_a_cancelled_request_still_rolls_back(backend, monkeypatch):
|
|
"""The MCP SDK cancels a request by cancelling an anyio scope; the rollback
|
|
runs under a shield, so the cancelled plan leaves nothing behind."""
|
|
from engineering.arch import rooms
|
|
|
|
async def hang(*_args, **_kwargs):
|
|
await anyio.sleep_forever()
|
|
|
|
monkeypatch.setattr(rooms, "label_room", hang)
|
|
with anyio.move_on_after(0.5):
|
|
await draw_plan_from_spec(backend, EXAMPLE_SPEC)
|
|
assert await _count(backend) == 0
|
|
begun = await backend.transaction_begin()
|
|
assert begun["ok"] is True
|
|
await backend.transaction_rollback()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_the_tool_is_registered_and_refuses_through_tool_error():
|
|
from fastmcp import Client
|
|
from fastmcp.exceptions import ToolError
|
|
|
|
import server
|
|
|
|
async with Client(server.mcp) as client:
|
|
await client.call_tool("drawing_new", {})
|
|
result = (
|
|
await client.call_tool("arch_plan_from_spec", {"spec": EXAMPLE_SPEC})
|
|
).structured_content
|
|
assert result["critique"] == []
|
|
with pytest.raises(ToolError, match=r"walls: name at least one wall"):
|
|
await client.call_tool("arch_plan_from_spec", {"spec": {"walls": []}})
|