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[A-Za-z0-9]+(?:-[A-Za-z0-9]+)+)-(?P[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", ]