aps-agent/server/aps_domain/drawing_understanding.py

909 lines
38 KiB
Python
Raw Permalink 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.

from __future__ import annotations
"""Drawing understanding and engineering-review evidence.
This module consumes the stable inspect_dxf output and, when the source
file is available, re-opens the DXF for geometry and text clustering. It
never writes master data: every inferred field is a PENDING_REVIEW candidate
with confidence and evidence.
"""
import re
from datetime import UTC, datetime
from hashlib import sha256
from pathlib import Path
from typing import Any
UNDERSTANDING_VERSION = "drawing-understanding.v1"
LINK_EVIDENCE_VERSION = "drawing-link-evidence.v1"
DIFF_VERSION = "drawing-version-diff.v1"
_DRAWING_NAME_RE = re.compile(
r"^(?P<drawing_no>[A-Za-z0-9]+(?:-[A-Za-z0-9]+)+)-(?P<revision>[A-Za-z])(?:\(\d+\))?$"
)
_ITEM_REF_RE = re.compile(r"^(?:[A-Z]?\d{2,}[.]\d{2,}[A-Z]?|\d{6,}(?:-\d+)?)$", re.IGNORECASE)
_HEADER_TOKEN_RE = re.compile(
r"^(?:POS(?:ITION)?|NR|NO\.?|BENENNUNG|DESIGNATION|TITLE|SHEET|BLATT|"
r"MENGE|QTY|QUANTITY|ANZAHL|STUECK(?:LISTE)?|MATERIAL|WERKSTOFF|"
r"SACHNR|PART(?:S)?|TEILE)$",
re.IGNORECASE,
)
_POSITION_HEADER_RE = re.compile(r"^(?:POS(?:ITION)?|NR|NO\.?)$", re.IGNORECASE)
_QUANTITY_HEADER_RE = re.compile(r"^(?:MENGE|QTY|QUANTITY|ANZAHL)$", re.IGNORECASE)
_SHEET_HEADER_RE = re.compile(r"^(?:SHEET|BLATT)$", re.IGNORECASE)
_TABLE_LAYER_RE = re.compile(r"(?:LISTE|STUECK|BOM|POS|TAB|PARTS|TEILE)", re.IGNORECASE)
_TABLE_BLOCK_RE = re.compile(r"(?:POS|STUECK|BOM|LISTE|TAB|PARTS|TEILE)", re.IGNORECASE)
_FRAME_BLOCK_RE = re.compile(r"^(?:STD|RAHMEN|FRAME|BORDER)_?", re.IGNORECASE)
_TITLE_BLOCK_RE = re.compile(r"^(?:SF_STD|TITLE|TITEL|SCHRIFTFELD|ZEICHNUNGSRAHMEN)", re.IGNORECASE)
_THREAD_SPEC_RE = re.compile(r"\b(?:M|G)\d+(?:[xX*]\d+(?:\.\d+)?)?(?!\s*[::]\s*\d)")
_MATERIAL_WORD_RE = re.compile(
r"\b(?:STAHL|STEEL|ALU(?:MINIUM)?|KUPFER|COPPER|EDELSTAHL|STAINLESS|MESSING|BRASS)\b",
re.IGNORECASE,
)
_TITLE_DRAWING_KEYS = (
"ZEICHNUNGSNUMMER",
"DRAWING_NO",
"DRAWINGNUMBER",
"DWG_NO",
"DRAWING_NUMBER",
)
_TITLE_REVISION_KEYS = ("B_FREI", "REVISION", "REV", "REV_LTR", "REVISION_LETTER")
_TITLE_SHEET_KEYS = ("BLATTNUMMER", "SHEET", "SHEET_NO", "SHEETNUMBER")
_TITLE_NAME_KEYS = ("BENENNUNG1", "BENENNUNG2", "BENENNUNG3", "TITLE", "DRAWING_TITLE")
_TITLE_SCALE_KEYS = ("GEN-TITLE-SCA{5.42}", "SCALE", "MASSSTAB")
_TITLE_MATERIAL_KEYS = ("WERKSTOFF", "MATERIAL", "MATERIAL_SPEC", "MATERIAL_SPECIFICATION")
_TITLE_SURFACE_KEYS = ("OBERFLAECHE", "SURFACE", "SURFACE_SPEC")
_TITLE_MASS_KEYS = ("MASSE", "MASS", "WEIGHT")
_TITLE_FORMAT_KEYS = ("FORMAT", "SIZE", "FORMAT_SIZE")
_TITLE_HALBZEUG_KEYS = ("HALBZEUG", "SEMIFINISHED", "SEMI_FINISHED")
def utc_now() -> str:
return datetime.now(UTC).isoformat().replace("+00:00", "Z")
def _stable_id(kind: str, digest: str, suffix: str = "") -> str:
seed = f"{kind}:{digest}:{suffix}".encode()
return f"{kind}_{sha256(seed).hexdigest()[:20]}"
def _clean_text(value: Any) -> str:
text = str(value or "")
text = text.replace("\\P", "\n").replace("%%d", "°")
text = re.sub(r"[{}]", "", text)
return "\n".join(part.strip() for part in text.splitlines() if part.strip())
def _iter_text_lines(value: Any):
for line in _clean_text(value).splitlines():
token = line.strip()
if token:
yield token
def _to_number(value: str) -> int | float | str | None:
try:
return int(value)
except ValueError:
try:
return float(value)
except ValueError:
return None
def _bbox_payload(min_x: float, min_y: float, max_x: float, max_y: float) -> dict[str, Any]:
return {
"min": [round(min_x, 6), round(min_y, 6)],
"max": [round(max_x, 6), round(max_y, 6)],
"width": round(max_x - min_x, 6),
"height": round(max_y - min_y, 6),
}
def _point_in_bbox(point: dict[str, float], bbox: dict[str, Any] | None) -> bool:
if not bbox or point.get("x") is None or point.get("y") is None:
return False
min_x, min_y = bbox.get("min", [float("-inf"), float("-inf")])
max_x, max_y = bbox.get("max", [float("inf"), float("inf")])
return min_x <= point["x"] <= max_x and min_y <= point["y"] <= max_y
def _load_doc(parsed: dict[str, Any]):
source = (parsed.get("asset") or {}).get("sourcePath") or parsed.get("sourcePath")
if not source:
return None
try:
import ezdxf
return ezdxf.readfile(Path(source))
except Exception: # noqa: BLE001
return None
def _layout_texts(doc: Any, digest: str) -> list[dict[str, Any]]:
rows: list[dict[str, Any]] = []
seen: set[str] = set()
def add_row(entity: Any, space: str) -> None:
raw = entity.text if entity.dxftype() == "MTEXT" else getattr(entity.dxf, "text", "")
clean = _clean_text(raw)
if not clean:
return
insert = getattr(entity.dxf, "insert", None)
if insert is None:
return
key = f"{space}:{getattr(entity.dxf, 'handle', '')!s}"
if key in seen:
return
seen.add(key)
rows.append({
"id": _stable_id("understandingText", digest, key),
"space": space,
"x": round(float(insert[0]), 6),
"y": round(float(insert[1]), 6),
"layer": str(getattr(entity.dxf, "layer", "0") or "0"),
"text": clean,
})
for layout in doc.layouts:
space = str(layout.name or "Model")
for entity in layout:
kind = entity.dxftype()
if kind in {"TEXT", "MTEXT", "ATTRIB"}:
add_row(entity, space)
elif kind == "INSERT":
for attrib in getattr(entity, "attribs", []):
add_row(attrib, space)
if _TABLE_BLOCK_RE.search(str(getattr(entity.dxf, "name", "") or "")):
try:
for sub in entity.virtual_entities():
if sub.dxftype() in {"TEXT", "MTEXT", "ATTRIB"}:
add_row(sub, space)
except Exception: # noqa: BLE001, S110
pass
return rows
def _insert_bbox(doc: Any, insert: Any) -> dict[str, Any] | None:
try:
from ezdxf import bbox
box = bbox.extents(list(insert.virtual_entities()), fast=True)
if box and box.has_data:
return _bbox_payload(float(box.extmin.x), float(box.extmin.y),
float(box.extmax.x), float(box.extmax.y))
block = doc.blocks.get(getattr(insert.dxf, "name", "") or "")
if block is not None:
box = bbox.extents(list(block), fast=True)
if box and box.has_data:
ix, iy = float(insert.dxf.insert[0]), float(insert.dxf.insert[1])
return _bbox_payload(ix + float(box.extmin.x), iy + float(box.extmin.y),
ix + float(box.extmax.x), iy + float(box.extmax.y))
except Exception: # noqa: BLE001, S110
pass
return None
def _extract_title_block(parsed: dict[str, Any], doc: Any) -> dict[str, Any] | None:
if doc is None:
return None
for layout in doc.layouts:
for entity in layout:
if entity.dxftype() != "INSERT":
continue
attrs = {
str(getattr(attrib.dxf, "tag", "") or ""): _clean_text(getattr(attrib.dxf, "text", ""))
for attrib in getattr(entity, "attribs", [])
}
name = str(getattr(entity.dxf, "name", "") or "")
is_title = bool(_TITLE_BLOCK_RE.search(name)) or any(
key in attrs for key in _TITLE_DRAWING_KEYS
)
if not is_title:
continue
meaningful = {k: v for k, v in attrs.items() if v and v != "."}
return {
"blockName": name,
"space": str(layout.name or ""),
"bbox": _insert_bbox(doc, entity),
"attributes": meaningful,
"attributeCount": len(meaningful),
}
return None
def _extract_frame(parsed: dict[str, Any], doc: Any) -> dict[str, Any] | None:
if doc is None:
return None
for layout in doc.layouts:
for entity in layout:
if entity.dxftype() != "INSERT":
continue
name = str(getattr(entity.dxf, "name", "") or "")
if name.startswith("SF_") or not _FRAME_BLOCK_RE.search(name):
continue
box = _insert_bbox(doc, entity)
if box:
return {
"blockName": name,
"space": str(layout.name or ""),
"bbox": box,
"confidence": 0.9,
"evidence": {"source": "layoutBlockGeometry", "blockName": name},
}
try:
from ezdxf import bbox
box = bbox.extents(doc.modelspace(), fast=True)
if box and box.has_data:
return {
"blockName": None,
"space": "Model",
"bbox": _bbox_payload(float(box.extmin.x), float(box.extmin.y),
float(box.extmax.x), float(box.extmax.y)),
"confidence": 0.6,
"evidence": {"source": "modelspaceExtents"},
}
except Exception: # noqa: BLE001, S110
pass
return None
def _header_rows(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
by_y: dict[float, list[dict[str, Any]]] = {}
for row in rows:
for line in _iter_text_lines(row["text"]):
if _HEADER_TOKEN_RE.fullmatch(line):
by_y.setdefault(row["y"], []).append({"label": line, "x": row["x"], "y": row["y"]})
merged: list[dict[str, Any]] = []
for y in sorted(by_y):
cells = by_y[y]
if merged and y - merged[-1]["maxY"] <= 8.0:
merged[-1]["cells"].extend(cells)
merged[-1]["maxY"] = y
else:
merged.append({"minY": y, "maxY": y, "cells": cells})
return [row for row in merged if len(row["cells"]) >= 3]
def _column_anchors(region_rows: list[dict[str, Any]]) -> list[float]:
anchors: list[float] = []
for row in region_rows:
for line in _iter_text_lines(row["text"]):
if _POSITION_HEADER_RE.fullmatch(line) and (not anchors or abs(row["x"] - anchors[-1]) > 1.0):
anchors.append(row["x"])
if anchors:
return sorted(anchors)
xs = sorted({row["x"] for row in region_rows})
grouped: list[float] = []
for x in xs:
if not grouped or x - grouped[-1] > 15.0:
grouped.append(x)
return grouped
def _detect_bom_regions(parsed: dict[str, Any], doc: Any, title: dict[str, Any] | None) -> list[dict[str, Any]]:
if doc is None:
return []
digest = str((parsed.get("asset") or {}).get("sha256") or "")
rows = _layout_texts(doc, digest)
preferred = [row for row in rows if _TABLE_LAYER_RE.search(row["layer"])]
if preferred:
rows = preferred
else:
rows = [row for row in rows if row["space"] == "Model"]
title_box = (title or {}).get("bbox")
if title_box:
rows = [row for row in rows if not _point_in_bbox(row, title_box)]
if not rows:
return []
headers = _header_rows(rows)
regions: list[dict[str, Any]] = []
for header in headers:
cells = header["cells"]
min_x = min(cell["x"] for cell in cells) - 8.0
max_x = max(cell["x"] for cell in cells) + 8.0
min_y = min(cell["y"] for cell in cells) - 3.0
max_y = max(cell["y"] for cell in cells) + 3.0
in_x = [row for row in rows if min_x <= row["x"] <= max_x]
data_rows = [row for row in in_x if row["y"] < header["minY"] - 2.0 or row["y"] > header["maxY"] + 2.0]
if data_rows:
min_y = min(min_y, min(row["y"] for row in data_rows) - 3.0)
max_y = max(max_y, max(row["y"] for row in data_rows) + 8.0)
region_id = _stable_id("bomRegion", digest,
f"{header['minY']:.2f}:{header['maxY']:.2f}:{min_x:.1f}:{max_x:.1f}")
layer_names = sorted({row["layer"] for row in in_x if row["layer"]})
header_labels = sorted({cell["label"].upper() for cell in cells})
has_quantity = any(_QUANTITY_HEADER_RE.fullmatch(label) for label in header_labels)
has_sheet = any(_SHEET_HEADER_RE.fullmatch(label) for label in header_labels)
regions.append({
"regionId": region_id,
"kind": "BOM_TABLE",
"space": next((row["space"] for row in in_x if row["space"]), "Model"),
"bbox": _bbox_payload(min_x, min_y, max_x, max_y),
"headerLabels": header_labels,
"headerCells": cells[:40],
"columnAnchors": _column_anchors(in_x),
"hasQuantityColumn": has_quantity,
"hasSheetColumn": has_sheet,
"layerNames": layer_names,
"confidence": round(min(0.95, 0.72 + 0.02 * len(header_labels)), 3),
"reviewRequired": True,
"evidence": {
"source": "layoutTextClustering",
"headerTokenCount": len(cells),
"layerNames": layer_names,
},
})
return regions
def _column_bounds(anchors: list[float], index: int, min_x: float, max_x: float) -> tuple[float, float]:
if not anchors:
return min_x, max_x
left = min_x if index == 0 else (anchors[index - 1] + anchors[index]) / 2.0
right = max_x if index >= len(anchors) - 1 else (anchors[index] + anchors[index + 1]) / 2.0
return left, right
def _pick_name(texts: list[dict[str, Any]]) -> str | None:
candidates = []
for row in texts:
for line in _iter_text_lines(row["text"]):
token = line.strip()
if len(token) < 3 or _HEADER_TOKEN_RE.fullmatch(token) or _ITEM_REF_RE.fullmatch(token):
continue
if token.isdigit() or token == ".":
continue
candidates.append(token)
return max(candidates, key=len) if candidates else None
def _part_list_rows(parsed: dict[str, Any], doc: Any, regions: list[dict[str, Any]]) -> list[dict[str, Any]]:
digest = str((parsed.get("asset") or {}).get("sha256") or "")
drawing_no = str((parsed.get("drawing") or {}).get("drawingNumber") or "")
rows = _layout_texts(doc, digest) if doc is not None else []
out: list[dict[str, Any]] = []
seen: set[str] = set()
for region in regions:
min_x, min_y = region["bbox"]["min"]
max_x, max_y = region["bbox"]["max"]
in_region = [row for row in rows if min_x <= row["x"] <= max_x and min_y <= row["y"] <= max_y]
ref_rows = [
row for row in in_region
if any(_ITEM_REF_RE.fullmatch(line) for line in _iter_text_lines(row["text"]))
]
for ref_row in ref_rows:
ref_y = ref_row["y"]
group = [row for row in in_region if abs(row["y"] - ref_y) <= 10.0]
refs = sorted({line for line in _iter_text_lines(ref_row["text"]) if _ITEM_REF_RE.fullmatch(line)})
anchors = region.get("columnAnchors") or []
for ref in refs:
index = min(range(len(anchors)), key=lambda i: abs(anchors[i] - ref_row["x"])) if anchors else 0
left, right = _column_bounds(anchors, index, min_x, max_x)
column_texts = [row for row in group if left <= row["x"] <= right]
key = f"{ref}:{ref_y:.2f}:{index}"
if key in seen:
continue
seen.add(key)
numeric = [
_to_number(line)
for row in column_texts
for line in _iter_text_lines(row["text"])
if not _ITEM_REF_RE.fullmatch(line) and _to_number(line) is not None
]
quantity = None
sheet = None
if region.get("hasQuantityColumn") and numeric:
quantity = numeric[0]
if region.get("hasSheetColumn") and numeric:
sheet = str(numeric[0])
name = _pick_name(column_texts)
candidate_id = _stable_id("partList", digest, key)
out.append({
"candidateId": candidate_id,
"candidateType": "PART_LIST_ROW",
"status": "PENDING_REVIEW",
"reviewRequired": True,
"parentDrawingNumber": drawing_no,
"componentReference": ref,
"componentName": name,
"quantity": quantity,
"unit": None,
"sheetReference": sheet,
"confidence": round(0.74 if name else 0.6, 3),
"unknownFields": ["componentMaterialCode", "unit", "materialSpecification"],
"evidence": {
"source": "bomRegionText",
"regionId": region["regionId"],
"space": region["space"],
"rowY": round(ref_y, 3),
"columnGroup": index,
"texts": [
{"text": row["text"], "x": row["x"], "y": row["y"], "layer": row["layer"]}
for row in column_texts[:12]
],
},
})
if not out:
for row in parsed.get("itemReferences") or []:
ref = str(row.get("reference") or "").strip()
if not ref or ref in seen:
continue
seen.add(ref)
out.append({
"candidateId": _stable_id("partList", digest, f"callout:{ref}"),
"candidateType": "PART_LIST_ROW",
"status": "PENDING_REVIEW",
"reviewRequired": True,
"parentDrawingNumber": drawing_no,
"componentReference": ref,
"componentName": None,
"quantity": None,
"unit": None,
"sheetReference": None,
"confidence": round(min(float(row.get("confidence") or 0.0), 0.72), 3),
"unknownFields": ["componentMaterialCode", "quantity", "unit", "materialSpecification"],
"evidence": {"source": "inspectItemReference", "entity": row.get("evidence")},
})
return out
def _assembly_relations(part_rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
seen: set[str] = set()
for row in part_rows:
parent = str(row.get("parentDrawingNumber") or "").strip()
ref = str(row.get("componentReference") or "").strip()
if not parent or not ref or ref == parent:
continue
key = f"{parent}:{ref}"
if key in seen:
continue
seen.add(key)
out.append({
"candidateId": _stable_id("assemblyRelation", str((row.get("evidence") or {}).get("regionId") or ""), key),
"candidateType": "ASSEMBLY_RELATION",
"status": "PENDING_REVIEW",
"reviewRequired": True,
"parentDrawingNumber": parent,
"parentMaterialCode": parent,
"componentReference": ref,
"componentName": row.get("componentName"),
"quantity": row.get("quantity"),
"relationshipType": "PART_OF",
"confidence": round(min(float(row.get("confidence") or 0.0), 0.7), 3),
"unknownFields": ["componentMaterialCode", "quantity"],
"evidence": {"source": "assemblyRelationFromPartList", "partListCandidateId": row.get("candidateId")},
})
return out
def _material_spec_candidates(title: dict[str, Any] | None, doc: Any,
digest: str) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
seen: set[str] = set()
attrs = (title or {}).get("attributes") or {}
attr_fields = [
(_TITLE_MATERIAL_KEYS, "materialSpecification"),
(_TITLE_SURFACE_KEYS, "surfaceSpecification"),
(_TITLE_MASS_KEYS, "mass"),
(_TITLE_HALBZEUG_KEYS, "halfFinishedSpecification"),
]
for keys, field in attr_fields:
value = next((str(attrs[k]).strip() for k in keys if attrs.get(k)), None)
if value and value != "." and field not in seen:
seen.add(field)
out.append({
"candidateId": _stable_id("materialSpec", digest, field),
"candidateType": "MATERIAL_SPEC",
"status": "PENDING_REVIEW",
"reviewRequired": True,
"field": field,
"value": value,
"confidence": 0.82,
"evidence": {"source": "titleBlockAttribute", "attribute": field},
})
rows = _layout_texts(doc, digest) if doc is not None else []
title_box = (title or {}).get("bbox")
for row in rows:
if title_box and _point_in_bbox(row, title_box):
continue
for line in _iter_text_lines(row["text"]):
matches = [*_THREAD_SPEC_RE.finditer(line), *_MATERIAL_WORD_RE.finditer(line)]
for match in matches:
field = "threadSpecification" if _THREAD_SPEC_RE.fullmatch(match.group(0)) else "materialSpecificationHint"
key = f"text:{field}:{match.group(0)}"
if key in seen or len(out) >= 40:
continue
seen.add(key)
out.append({
"candidateId": _stable_id("materialSpec", digest, key),
"candidateType": "MATERIAL_SPEC",
"status": "PENDING_REVIEW",
"reviewRequired": True,
"field": field,
"value": match.group(0),
"confidence": 0.55,
"evidence": {
"source": "drawingText",
"text": line,
"position": [row["x"], row["y"]],
"layer": row["layer"],
},
})
return out
def _quantity_candidates(part_rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
for row in part_rows:
quantity = row.get("quantity")
if quantity is None:
continue
out.append({
"candidateId": _stable_id("quantity", str((row.get("evidence") or {}).get("regionId") or ""),
str(row.get("componentReference") or "")),
"candidateType": "QUANTITY",
"status": "PENDING_REVIEW",
"reviewRequired": True,
"field": "BOM.quantity",
"value": quantity,
"componentReference": row.get("componentReference"),
"confidence": 0.68,
"evidence": {"source": "bomRegionText", "partListCandidateId": row.get("candidateId")},
})
return out
def _drawing_no_relation(inner: str | None, filename_no: str | None) -> tuple[str, float]:
a = re.sub(r"\s+", "", inner or "").upper()
b = re.sub(r"\s+", "", filename_no or "").upper()
if not a or not b:
return "missing", 0.5
if a == b:
return "exact", 0.97
if len(a) >= 4 and (b.startswith(a) or a.startswith(b)):
return "partial", 0.86
return "mismatch", 0.4
def _revision_relation(inner: str | None, filename_rev: str | None) -> tuple[str, float]:
a = str(inner or "").strip().lower()
b = str(filename_rev or "").strip().lower()
if not a:
return "missing", 0.5
if not b:
return "innerOnly", 0.72
if a == b:
return "exact", 0.95
return "mismatch", 0.35
def _field_candidate(field: str, value: Any, confidence: float, evidence: dict[str, Any],
*, review_required: bool) -> dict[str, Any]:
return {
"field": field,
"value": value,
"confidence": round(max(0.0, min(float(confidence), 1.0)), 3),
"reviewRequired": bool(review_required),
"evidence": evidence,
}
def _title_value(attrs: dict[str, str], keys: tuple[str, ...]) -> str | None:
for key in keys:
value = str(attrs.get(key) or "").strip()
if value and value != ".":
return value
return None
def build_drawing_understanding(parsed: dict[str, Any]) -> dict[str, Any]:
"""Build PENDING_REVIEW drawing-understanding candidates from inspect_dxf output."""
asset = parsed.get("asset") or {}
drawing = parsed.get("drawing") or {}
digest = str(asset.get("sha256") or "")
if not digest or len(digest) != 64:
raise ValueError("parsed drawing is missing a valid sha256")
filename_no = str(drawing.get("drawingNumber") or "").strip()
filename_rev = drawing.get("revision")
doc = _load_doc(parsed)
title = _extract_title_block(parsed, doc)
frame = _extract_frame(parsed, doc)
bom_regions = _detect_bom_regions(parsed, doc, title)
part_rows = _part_list_rows(parsed, doc, bom_regions)
relations = _assembly_relations(part_rows)
spec_candidates = _material_spec_candidates(title, doc, digest)
quantity_candidates = _quantity_candidates(part_rows)
from server.aps_domain.drawing_process import recognize_process_candidates
process = recognize_process_candidates(parsed, part_rows)
process_ops = process.get("operations") or []
process_details = process.get("details") or []
fields: list[dict[str, Any]] = []
title_attrs = (title or {}).get("attributes") or {}
inner_no = _title_value(title_attrs, _TITLE_DRAWING_KEYS)
inner_rev = _title_value(title_attrs, _TITLE_REVISION_KEYS)
no_rel, no_conf = _drawing_no_relation(inner_no, filename_no)
rev_rel, rev_conf = _revision_relation(inner_rev, filename_rev)
fields.append(_field_candidate(
"drawingNumber", filename_no, no_conf,
{"source": "crossValidation", "filename": filename_no, "titleBlock": inner_no,
"relation": no_rel},
review_required=no_rel != "exact",
))
revision_value = filename_rev if filename_rev else inner_rev
fields.append(_field_candidate(
"revision", revision_value, rev_conf,
{"source": "crossValidation", "filename": filename_rev, "titleBlock": inner_rev,
"relation": rev_rel},
review_required=rev_rel != "exact",
))
title_fields = [
("sheetNo", _title_value(title_attrs, _TITLE_SHEET_KEYS), "titleBlockAttribute"),
("title", _title_value(title_attrs, _TITLE_NAME_KEYS), "titleBlockAttribute"),
("scale", _title_value(title_attrs, _TITLE_SCALE_KEYS), "titleBlockAttribute"),
("format", _title_value(title_attrs, _TITLE_FORMAT_KEYS), "titleBlockAttribute"),
]
for field, value, source in title_fields:
if value:
fields.append(_field_candidate(
field, value, 0.85, {"source": source, "blockName": (title or {}).get("blockName")},
review_required=True,
))
for detail in process_details:
fields.append(_field_candidate(
detail["field"], detail["value"], float(detail["confidence"]),
detail["evidence"], review_required=True,
))
warnings = [
"Candidates must pass engineering review/P2 approval before master-data write.",
"No BOM quantity, standard time, or production resource is inferred from geometry alone.",
]
if no_rel == "partial":
warnings.append("Title-block drawing number partially matches the filename (sheet/page suffix); review required.")
if not bom_regions:
warnings.append("No explicit BOM table detected; part references are treated as callout evidence and remain PENDING_REVIEW.")
if not quantity_candidates:
warnings.append("No explicit quantity column detected; quantity candidates are not fabricated.")
if not process_ops:
warnings.append("No explicit process/operation keyword detected in drawing text or part-list names; routing stays as engineering-review placeholder.")
return {
"contractVersion": UNDERSTANDING_VERSION,
"asset": {"id": asset.get("id"), "sha256": digest, "filename": asset.get("filename"),
"sourcePath": asset.get("sourcePath")},
"drawing": {"drawingNumber": filename_no, "revision": filename_rev},
"regions": {
"titleBlock": title,
"frame": frame,
"bomTables": bom_regions,
},
"fieldCandidates": fields,
"partListRows": part_rows,
"assemblyRelations": relations,
"materialSpecCandidates": spec_candidates,
"quantityCandidates": quantity_candidates,
"processCandidates": process_ops,
"processDetails": process_details,
"routingOperations": process_ops,
"status": "PENDING_REVIEW",
"reviewRequired": True,
"warnings": warnings,
}
def diff_drawing_inspect(older: dict[str, Any], newer: dict[str, Any]) -> dict[str, Any]:
"""Produce a reviewable diff summary and reschedule-impact hint from two inspect results."""
old_asset = older.get("asset") or {}
new_asset = newer.get("asset") or {}
old_drawing = older.get("drawing") or {}
new_drawing = newer.get("drawing") or {}
old_stats = older.get("entityStatistics") or {}
new_stats = newer.get("entityStatistics") or {}
old_refs = {str(row.get("reference") or "") for row in (older.get("itemReferences") or [])}
new_refs = {str(row.get("reference") or "") for row in (newer.get("itemReferences") or [])}
entity_delta: dict[str, dict[str, Any]] = {}
for key in sorted(set(old_stats) | set(new_stats)):
old_value = int(old_stats.get(key, 0) or 0)
new_value = int(new_stats.get(key, 0) or 0)
if old_value != new_value:
entity_delta[key] = {"old": old_value, "new": new_value, "delta": new_value - old_value}
old_fields = {str(row.get("field") or ""): row.get("value") for row in (older.get("fieldCandidates") or [])}
new_fields = {str(row.get("field") or ""): row.get("value") for row in (newer.get("fieldCandidates") or [])}
field_changes = [
{"field": key, "old": old_fields.get(key), "new": new_fields.get(key)}
for key in sorted(set(old_fields) | set(new_fields))
if old_fields.get(key) != new_fields.get(key)
]
old_bbox = old_drawing.get("bbox") or {}
new_bbox = new_drawing.get("bbox") or {}
geometry_changed = (
abs(float(old_bbox.get("width") or 0.0) - float(new_bbox.get("width") or 0.0)) > 0.5
or abs(float(old_bbox.get("height") or 0.0) - float(new_bbox.get("height") or 0.0)) > 0.5
or bool(entity_delta)
)
part_list_changed = bool(new_refs.symmetric_difference(old_refs))
revision_changed = str(old_drawing.get("revision") or "") != str(new_drawing.get("revision") or "")
drawing_no_changed = str(old_drawing.get("drawingNumber") or "") != str(new_drawing.get("drawingNumber") or "")
sha_changed = str(old_asset.get("sha256") or "") != str(new_asset.get("sha256") or "")
text_count_changed = len(older.get("texts") or []) != len(newer.get("texts") or [])
dimension_count_changed = len(older.get("dimensions") or []) != len(newer.get("dimensions") or [])
changed = (
sha_changed or drawing_no_changed or revision_changed or geometry_changed
or part_list_changed or text_count_changed or dimension_count_changed or bool(field_changes)
)
if revision_changed and (geometry_changed or part_list_changed or dimension_count_changed):
impact_level = "HIGH"
elif revision_changed or part_list_changed or geometry_changed or dimension_count_changed:
impact_level = "MEDIUM"
else:
impact_level = "LOW"
reasons = []
if sha_changed:
reasons.append("file content hash changed")
if revision_changed:
reasons.append(f"revision changed {old_drawing.get('revision')!r} -> {new_drawing.get('revision')!r}")
if part_list_changed:
reasons.append("part reference set changed")
if geometry_changed:
reasons.append("entity statistics or drawing extents changed")
if dimension_count_changed:
reasons.append("dimension count changed")
return {
"contractVersion": DIFF_VERSION,
"changed": bool(changed),
"asset": {
"sameSha": not sha_changed,
"oldSha": old_asset.get("sha256"),
"newSha": new_asset.get("sha256"),
},
"drawing": {
"oldDrawingNumber": old_drawing.get("drawingNumber"),
"newDrawingNumber": new_drawing.get("drawingNumber"),
"oldRevision": old_drawing.get("revision"),
"newRevision": new_drawing.get("revision"),
},
"summary": {
"oldTexts": len(older.get("texts") or []),
"newTexts": len(newer.get("texts") or []),
"oldDimensions": len(older.get("dimensions") or []),
"newDimensions": len(newer.get("dimensions") or []),
"oldBbox": old_bbox,
"newBbox": new_bbox,
},
"entityStatistics": entity_delta,
"fieldChanges": field_changes,
"itemReferences": {
"added": sorted(new_refs - old_refs),
"removed": sorted(old_refs - new_refs),
},
"impact": {
"level": impact_level,
"suggestReschedule": bool(changed and impact_level in {"HIGH", "MEDIUM"}),
"reasons": reasons,
},
"warnings": [
"Version diff is evidence-only; rescheduling requires engineering/P2 review.",
],
}
def _order_codes(order: dict[str, Any]) -> list[str]:
codes = [str(order.get("productCode") or ""), str(order.get("materialCode") or "")]
for item in order.get("items") or []:
codes.extend([str(item.get("productCode") or ""), str(item.get("materialCode") or "")])
return [code for code in codes if code]
def _order_tables(world: dict[str, Any]) -> list[tuple[str, list[dict[str, Any]]]]:
return [
("flexOrders", world.get("flexOrders") or []),
("salesOrders", world.get("salesOrders") or []),
("productionOrders", world.get("productionOrders") or []),
("workOrders", world.get("workOrders") or []),
]
def build_drawing_link_evidence_records(
world: dict[str, Any],
*,
drawing_id: str,
source_sha256: str,
selected_candidates: list[dict[str, Any]],
actor: str,
confirm_id: str | None = None,
next_id: Any = None,
) -> list[dict[str, Any]]:
"""Build drawingLink evidence records for approved non-material candidates.
The workflow keeps material-row creation and its existing material link;
this helper adds BOM/ROUTING/ORDER evidence without writing master data.
"""
records: list[dict[str, Any]] = []
now = utc_now()
seen: set[tuple[str, str, str]] = set()
def add(link_type: str, target_type: str, target_id: Any, target_ref: str,
status: str, master_committed: bool, confidence: float,
candidate: dict[str, Any], extra: dict[str, Any]) -> None:
key = (link_type, target_type, target_ref)
if key in seen:
return
seen.add(key)
records.append({
"id": next_id("drawingLink") if next_id is not None else None,
"drawingId": drawing_id,
"linkType": link_type,
"targetType": target_type,
"targetId": target_id,
"targetRef": target_ref,
"sourceSha256": source_sha256,
"revision": candidate.get("revision"),
"status": status,
"masterCommitted": master_committed,
"confidence": round(max(0.0, min(float(confidence), 1.0)), 3),
"confirmedBy": actor,
"confirmedAt": now,
"evidence": {
"contractVersion": LINK_EVIDENCE_VERSION,
"confirmId": confirm_id,
"candidateId": candidate.get("candidateId"),
"candidateType": candidate.get("candidateType"),
"sourceSha256": source_sha256,
"drawingId": drawing_id,
**extra,
},
})
for candidate in selected_candidates or []:
ctype = str(candidate.get("candidateType") or "")
confidence = float(candidate.get("confidence") or 0.5)
if ctype == "MATERIAL":
code = str(candidate.get("code") or "").strip()
if not code:
continue
for table_name, orders in _order_tables(world):
for order in orders:
if code not in _order_codes(order):
continue
add(
"ORDER", "order", order.get("id"), str(order.get("orderNo") or ""),
"CONFIRMED", True, max(0.9, confidence), candidate,
{"orderTable": table_name, "productCode": code, "orderNo": order.get("orderNo")},
)
elif ctype == "BOM_REFERENCE":
ref = str(candidate.get("componentReference") or "").strip()
if not ref:
continue
add(
"BOM", "bom_reference", candidate.get("candidateId"), ref,
"PENDING_REVIEW", False, confidence, candidate,
{"parentMaterialCode": candidate.get("parentMaterialCode"), "componentReference": ref},
)
elif ctype == "ROUTING_OPERATION":
op = str(candidate.get("operationCode") or "").strip()
if not op:
continue
add(
"ROUTING", "routing_operation", candidate.get("candidateId"), op,
"PENDING_REVIEW", False, confidence, candidate,
{"productCode": candidate.get("productCode"), "operationCode": op},
)
return records
__all__ = [
"build_drawing_link_evidence_records",
"build_drawing_understanding",
"diff_drawing_inspect",
"utc_now",
]