Files
autocad-mcp-pro/engineering/understand/vocab.py
T
Umut CANandClaude Opus 5.5 9ab0ae1773 feat(understand): the snapshot, the vocabulary and the label parsers; MAX_DXF_BYTES 512 MB; the office extra
Track H's foundation, landed on main before its three groups branch.

- engineering/understand/snapshot.py: one bulk read of a drawing into frozen
  WCS records (OCS translated through backends/ocs.py, text decoded, an
  INSERT boxed by its block geometry without its attribute texts). A DXF is
  read with ezdxf after the size check; the headless engine's document is
  read under its own lock; the live engine exports the active document with
  Document.Export(<tmp>/snap, "DXF", <set of all>) - never renamed, deleted
  afterwards - and a DWG is opened read-only, exported and closed. Measured on
  AutoCAD 2026: ".dxf" is refused with Invalid argument, the file lands as
  snap.DXF, model space, paper space and unused block definitions all reach
  it, and the selection set does not narrow a DXF export.
- engineering/understand/labels.py: %%c, \U+2205, the U+2300 sign, o-stroke
  and MTEXT format runs all read as one diameter spelling; sizes are kept as
  drawn and never equated; Cyrillic look-alike capitals fold into Latin tags;
  panels, wiring callouts and electrical data (kW, phases, volts, N, VFD) in
  EN / TR / RU / NL / DE. A power that is not written stays None.
- engineering/understand/vocab.py: layer classification with a confidence and
  the deciding keyword, supply / return words, room labels and block kinds in
  five languages. A CIP layer without a direction word is not guessed.
- MAX_DXF_BYTES defaults to 512 MB (a real layout was 188 MB); the refusal
  names the size and the variable. New `office` extra (openpyxl), also in
  `full`; uv.lock regenerated.

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

452 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Multilingual keyword tables for reading foreign drawings: EN / TR / RU / NL / DE.
A foreign drawing says what a layer carries in its name and in the texts on it,
in whatever language the office that drew it speaks: a P&ID may name
its piping layers in English and Dutch (``P_cipsupplyline``,
``KLEPPEN``) and label its lines in Russian. These tables are what lets the
reader tell a CIP supply line from a product line without a human.
They are **vocabulary, not a standard**: words a drafter writes, each with the
language it belongs to. A classification made from them carries a confidence
(0.9 for a direct keyword, 0.6 for weaker evidence) and the keyword that
decided it, so a reader can always see why.
Matching is done on a *folded* spelling - casefolded, diacritics stripped,
Turkish dotless ı made i - so ``DÖNÜŞ``, ``dönüş`` and ``donus`` are one word.
Cyrillic keeps its letters (``подача`` stays ``подача``). A keyword of five
letters or more matches anywhere in a layer name written without separators
(``cipsupplyline`` holds ``supply``); a shorter one must start a word, so
``air`` finds ``air_line`` and not ``stairs``.
"""
from __future__ import annotations
import re
import unicodedata
__all__ = [
"DISCIPLINES",
"EQUIPMENT_KINDS",
"LANGUAGES",
"SERVICES",
"classify_layer",
"equipment_kind",
"fold",
"room_label",
"supply_return",
]
LANGUAGES = ("en", "tr", "ru", "nl", "de")
SERVICES: tuple[str, ...] = (
"product",
"cip_supply",
"cip_return",
"glycol_supply",
"glycol_return",
"steam",
"condensate",
"cold_water",
"hot_water",
"ice_water",
"compressed_air",
"drain",
"electrical",
"unknown",
)
DISCIPLINES: tuple[str, ...] = (
"piping",
"electrical",
"architecture",
"equipment",
"annotation",
"structure",
"unknown",
)
EQUIPMENT_KINDS: tuple[str, ...] = (
"tank",
"pump",
"valve",
"reducer",
"motor",
"agitator",
"panel",
)
#: Confidence of a classification decided by one direct keyword.
DIRECT = 0.9
#: Confidence of a classification from weaker evidence: a CIP or glycol layer
#: whose name says no direction, a generic water layer.
WEAK = 0.6
#: A keyword this long matches anywhere in a separator-free name.
_SUBSTRING_MIN = 5
# (keyword, language) rows. Order matters inside a table: the first row that
# matches decides, so a longer or more specific word comes before a word it
# contains.
_SUPPLY_WORDS = (
("supply", "en"),
("besleme", "tr"),
("gidis", "tr"),
("подач", "ru"), # подача: supply
("подающ", "ru"), # подающий: supplying
("aanvoer", "nl"),
("vorlauf", "de"),
)
_RETURN_WORDS = (
("return", "en"),
("donus", "tr"),
("возврат", "ru"), # return
("обрат", "ru"), # обратка / обратный: return
("retour", "nl"),
("rucklauf", "de"),
)
_CIP_WORDS = (
("cip", "en"),
("мойк", "ru"), # мойка: washing, CIP
)
_GLYCOL_WORDS = (
("glycol", "en"),
("glikol", "tr"),
("гликол", "ru"), # гликоль: glycol
("glykol", "de"),
)
#: Services a layer name can state directly (CIP and glycol are decided with
#: their direction words above).
_SERVICE_WORDS = (
("product", "product", "en"),
("product", "urun", "tr"),
("product", "продукт", "ru"), # product
("product", "produkt", "de"),
("steam", "steam", "en"),
("steam", "buhar", "tr"),
("steam", "пар", "ru"), # steam
("steam", "stoom", "nl"),
("steam", "dampf", "de"),
("condensate", "condensate", "en"),
("condensate", "kondensat", "de"),
("condensate", "kondens", "tr"),
("condensate", "конденсат", "ru"), # condensate
("condensate", "condensaat", "nl"),
("ice_water", "ice water", "en"),
("ice_water", "buzlu su", "tr"),
("ice_water", "ледяная вода", "ru"),
("ice_water", "ijswater", "nl"),
("ice_water", "eiswasser", "de"),
("hot_water", "hot water", "en"),
("hot_water", "sicak su", "tr"),
("hot_water", "горячая вода", "ru"),
("hot_water", "warm water", "nl"),
("hot_water", "warmwasser", "de"),
("cold_water", "cold water", "en"),
("cold_water", "soguk su", "tr"),
("cold_water", "холодная вода", "ru"),
("cold_water", "koud water", "nl"),
("cold_water", "kaltwasser", "de"),
("compressed_air", "compressed air", "en"),
("compressed_air", "basincli hava", "tr"),
("compressed_air", "сжатый воздух", "ru"),
("compressed_air", "perslucht", "nl"),
("compressed_air", "druckluft", "de"),
("compressed_air", "air", "en"),
("compressed_air", "hava", "tr"),
("compressed_air", "воздух", "ru"), # air
("drain", "drain", "en"),
("drain", "sewer", "en"),
("drain", "drenaj", "tr"),
("drain", "atik su", "tr"),
("drain", "дренаж", "ru"), # drainage
("drain", "канализац", "ru"), # канализация: sewer
("drain", "riool", "nl"),
("drain", "abwasser", "de"),
)
_ELECTRICAL_WORDS = (
("electrical", "en"),
("electric", "en"),
("power", "en"),
("cable", "en"),
("elektrik", "tr"),
("kablo", "tr"),
("электр", "ru"), # электрика: electrical
("кабел", "ru"), # кабель: cable
("elektra", "nl"),
("kabel", "nl"),
("elektro", "de"),
)
#: Water words that name no temperature: a piping layer of unknown service.
_WATER_WORDS = (
("water", "en"),
("вода", "ru"), # water
("wasser", "de"),
)
_DISCIPLINE_WORDS = (
("piping", "piping", "en"),
("piping", "pipe", "en"),
("piping", "valve", "en"),
("piping", "boru", "tr"),
("piping", "tesisat", "tr"),
("piping", "vana", "tr"),
("piping", "труб", "ru"), # трубопровод: pipeline
("piping", "клапан", "ru"), # valve
("piping", "арматур", "ru"), # арматура: valves and fittings
("piping", "leiding", "nl"),
("piping", "klep", "nl"),
("piping", "afsluiter", "nl"),
("piping", "rohr", "de"),
("piping", "ventil", "de"),
("piping", "armatur", "de"),
("architecture", "wall", "en"),
("architecture", "door", "en"),
("architecture", "window", "en"),
("architecture", "duvar", "tr"),
("architecture", "kapi", "tr"),
("architecture", "pencere", "tr"),
("architecture", "стен", "ru"), # стена: wall
("architecture", "двер", "ru"), # дверь: door
("architecture", "окн", "ru"), # окно: window
("architecture", "muur", "nl"),
("architecture", "muren", "nl"),
("architecture", "deur", "nl"),
("architecture", "raam", "nl"),
("architecture", "wand", "de"),
("architecture", "fenster", "de"),
("equipment", "equipment", "en"),
("equipment", "tank", "en"),
("equipment", "pump", "en"),
("equipment", "ekipman", "tr"),
("equipment", "pompa", "tr"),
("equipment", "оборудован", "ru"), # оборудование: equipment
("equipment", "насос", "ru"), # pump
("equipment", "apparatuur", "nl"),
("equipment", "pomp", "nl"),
("equipment", "ausrustung", "de"),
("equipment", "pumpe", "de"),
("annotation", "text", "en"),
("annotation", "dim", "en"),
("annotation", "anno", "en"),
("annotation", "note", "en"),
("annotation", "title", "en"),
("annotation", "yazi", "tr"),
("annotation", "olcu", "tr"),
("annotation", "antet", "tr"),
("annotation", "текст", "ru"), # text
("annotation", "размер", "ru"), # dimension
("annotation", "штамп", "ru"), # title block
("annotation", "tekst", "nl"),
("annotation", "maat", "nl"),
("annotation", "bemassung", "de"),
("structure", "column", "en"),
("structure", "beam", "en"),
("structure", "steel", "en"),
("structure", "grid", "en"),
("structure", "kolon", "tr"),
("structure", "kiris", "tr"),
("structure", "celik", "tr"),
("structure", "aks", "tr"),
("structure", "колонн", "ru"), # колонна: column
("structure", "балк", "ru"), # балка: beam
("structure", "kolom", "nl"),
("structure", "balk", "nl"),
("structure", "staal", "nl"),
("structure", "stutze", "de"),
("structure", "trager", "de"),
("structure", "stahl", "de"),
)
_ROOM_WORDS = (
("room", "en"),
("oda", "tr"),
("mahal", "tr"),
("помещ", "ru"), # помещение: room
("комнат", "ru"), # комната: room
("ruimte", "nl"),
("kamer", "nl"),
("raum", "de"),
("zimmer", "de"),
)
_EQUIPMENT_KIND_WORDS = (
("reducer", "reducer", "en"),
("reducer", "reduksiyon", "tr"),
("reducer", "переход", "ru"), # reducer
("reducer", "verloop", "nl"),
("reducer", "reduzier", "de"),
("valve", "valve", "en"),
("valve", "vlv", "en"),
("valve", "vana", "tr"),
("valve", "клапан", "ru"), # valve
("valve", "кран", "ru"), # valve (cock)
("valve", "klep", "nl"),
("valve", "afsluiter", "nl"),
("valve", "ventil", "de"),
("valve", "armatur", "de"),
("agitator", "agitator", "en"),
("agitator", "mixer", "en"),
("agitator", "karistirici", "tr"),
("agitator", "мешалк", "ru"), # мешалка: agitator
("agitator", "roerwerk", "nl"),
("agitator", "ruhrwerk", "de"),
("pump", "pump", "en"),
("pump", "pompa", "tr"),
("pump", "насос", "ru"), # pump
("pump", "pomp", "nl"),
("pump", "pumpe", "de"),
("tank", "tank", "en"),
("tank", "vessel", "en"),
("tank", "емкост", "ru"), # емкость: vessel
("tank", "бак", "ru"), # tank
("tank", "vat", "nl"),
("tank", "behalter", "de"),
("motor", "motor", "en"),
("motor", "мотор", "ru"), # motor
("motor", "электродвиг", "ru"), # электродвигатель: electric motor
("panel", "panel", "en"),
("panel", "cabinet", "en"),
("panel", "pano", "tr"),
("panel", "шкаф", "ru"), # cabinet
("panel", "щит", "ru"), # switchboard
("panel", "schakelkast", "nl"),
("panel", "kast", "nl"),
("panel", "schrank", "de"),
)
def fold(text: str | None) -> str:
"""Casefolded, diacritics stripped, Turkish ı made i; Cyrillic letters kept."""
if not text:
return ""
decomposed = unicodedata.normalize("NFKD", str(text).casefold().replace("ı", "i"))
return "".join(ch for ch in decomposed if not unicodedata.combining(ch))
def _words(folded: str) -> list[str]:
return [w for w in re.split(r"[^\w]+|_|\d+", folded) if w]
def _compact(folded: str) -> str:
return "".join(w for w in _words(folded))
def _matches(keyword: str, folded: str) -> bool:
key = fold(keyword)
if " " in key or len(key) >= _SUBSTRING_MIN:
return key.replace(" ", "") in _compact(folded)
return any(word.startswith(key) for word in _words(folded))
def _first(rows, folded: str):
for row in rows:
if _matches(row[-2], folded):
return row
return None
def _direction(folded: str) -> tuple[str, str, str] | None:
"""("supply" | "return", keyword, language), or None when neither or both."""
supply = _first(_SUPPLY_WORDS, folded)
ret = _first(_RETURN_WORDS, folded)
if (supply is None) == (ret is None):
return None
if supply is not None:
return ("supply", *supply)
return ("return", *ret)
def _result(discipline, service, confidence, keyword, language) -> dict:
return {
"discipline": discipline,
"service": service,
"confidence": confidence,
"keyword": keyword,
"language": language,
}
def classify_layer(name: str) -> dict:
"""What a layer most likely carries, decided by the words in its name.
``{"discipline", "service", "confidence", "keyword", "language"}``. Order:
electrical words; CIP and glycol with a direction word (``P_cipsupplyline``
-> ``cip_supply``; without one, the service stays ``unknown`` at 0.6 - the
layer alone is not trusted to say which way a CIP line runs); the stated
services; a water word with no temperature (piping, ``unknown``, 0.6); the
discipline words. Nothing matched: ``unknown`` / ``unknown`` at 0.0.
"""
folded = fold(name)
row = _first(_ELECTRICAL_WORDS, folded)
if row is not None:
return _result("electrical", "electrical", DIRECT, *row)
for rows, stem in ((_CIP_WORDS, "cip"), (_GLYCOL_WORDS, "glycol")):
row = _first(rows, folded)
if row is None:
continue
direction = _direction(folded)
if direction is None:
return _result("piping", "unknown", WEAK, *row)
return _result("piping", f"{stem}_{direction[0]}", DIRECT, row[0], row[1])
row = _first(_SERVICE_WORDS, folded)
if row is not None:
return _result("piping", row[0], DIRECT, row[1], row[2])
row = _first(_WATER_WORDS, folded)
if row is not None:
return _result("piping", "unknown", WEAK, *row)
row = _first(_DISCIPLINE_WORDS, folded)
if row is not None:
return _result(row[0], "unknown", DIRECT, row[1], row[2])
return _result("unknown", "unknown", 0.0, None, None)
def supply_return(text: str | None) -> str | None:
"""``"supply"`` or ``"return"`` when the text says one of them, else None.
``ПОДАЧА`` / ``BESLEME`` / ``SUPPLY`` / ``AANVOER`` / ``VORLAUF`` are supply;
``ВОЗВРАТ`` / ``DÖNÜŞ`` / ``RETURN`` / ``RETOUR`` / ``RÜCKLAUF`` return. A
text naming both (``CIP SUPPLY / RETURN``) says neither and is None.
"""
direction = _direction(fold(text))
return None if direction is None else direction[0]
def room_label(text: str | None) -> dict | None:
"""A room label read: ``{"number", "name", "language"}``, or None.
A text is a room label when one of its words is a room word (*room*,
*oda*, *mahal*, *помещение*, *комната*, *ruimte*, *kamer*, *Raum*,
*Zimmer*). The number is the first word holding a digit; the name is what
is left, in the text's own spelling, or None. ``"ROOM 1 PROCESS HALL"`` ->
``{"number": "1", "name": "PROCESS HALL", "language": "en"}``.
"""
if not text:
return None
words = str(text).split()
for index, word in enumerate(words):
folded = fold(word)
for keyword, language in _ROOM_WORDS:
if folded.startswith(fold(keyword)):
rest = words[:index] + words[index + 1 :]
number = next((w for w in rest if any(ch.isdigit() for ch in w)), None)
name = " ".join(w for w in rest if w != number).strip() or None
return {"number": number, "name": name, "language": language}
return None
def equipment_kind(block_name: str | None) -> dict | None:
"""The kind of equipment a block name says it is: ``{"kind", "keyword", "language"}``.
``KLEP_VLINDER`` -> valve (nl); ``VERLOOPSTUK`` -> reducer (nl); an
anonymous ``*U12`` or a name with no keyword -> None.
"""
folded = fold(block_name)
row = _first(_EQUIPMENT_KIND_WORDS, folded)
if row is None:
return None
return {"kind": row[0], "keyword": row[1], "language": row[2]}