from __future__ import annotations import hashlib import re from collections import defaultdict from decimal import Decimal, InvalidOperation from typing import Any from .schema import Category, Characteristic, ReviewStatus TECHNICAL_LINE = re.compile( r"(?:[⌀Ø∅⌓⌖⏥⌯∥⊥±°]|\bM\s*\d|\bR\s*\d|\bRa\s*\d|" r"\d+(?:\.\d+)?\s*[±+\-/]|H\d\b|6H\b|THRU|TYP|MAX|MIN)", re.IGNORECASE, ) def enrich_characteristic(item: Characteristic) -> Characteristic: text = " ".join(part for part in (item.requirement, item.source_text) if part).strip() if not item.requirement: item.requirement = item.source_text if item.category == Category.OTHER: item.category = infer_category(text) if item.quantity is None: quantity = re.search(r"(? Category: normalized = text.strip() if re.search(r"\bM\s*\d+(?:\.\d+)?\s*[×xX]", normalized): return Category.THREAD if re.search(r"\bR[azq]\s*\d", normalized, re.I): return Category.SURFACE_FINISH if any(symbol in normalized for symbol in ("⌖", "⏥", "⌯", "∥", "⊥", "◎", "○")): return Category.GD_AND_T if re.search(r"[⌀Ø∅]\s*\d", normalized): return Category.DIAMETER if re.search(r"(?:^|\s)R\s*\d", normalized, re.I): return Category.RADIUS if "°" in normalized: return Category.ANGLE if re.search(r"\d", normalized) and any( token in normalized for token in ("±", "+", "-", "/", "MAX", "MIN") ): return Category.LINEAR_DIMENSION if re.search(r"MATERIAL|GRADE|HARDNESS|HBW|HRC", normalized, re.I): return Category.MATERIAL if normalized: return Category.NOTE return Category.OTHER def records_from_ocr_text(text: str, page: int = 1) -> list[Characteristic]: """Conservative fallback for OCR output; unknown associations remain unassigned.""" records: list[Characteristic] = [] counter = 1 for raw_line in text.splitlines(): line = re.sub(r"\s+", " ", raw_line).strip(" |\t") if len(line) < 2 or not TECHNICAL_LINE.search(line): continue balloon = "" requirement = line matched = re.match( r"^(?:BALLOON\s*)?[#(]?\s*(\d{1,4})\s*[)\]:.\-]\s*(.+)$", line, re.I, ) if matched: balloon, requirement = matched.group(1), matched.group(2) else: balloon = f"UNASSIGNED-{counter:03d}" counter += 1 record = Characteristic( balloon_id=balloon, page=page, requirement=requirement, source_text=line, confidence=0.35 if balloon.startswith("UNASSIGNED") else 0.5, status=( ReviewStatus.NEEDS_MAPPING if balloon.startswith("UNASSIGNED") else ReviewStatus.NEEDS_REVIEW ), ) records.append(enrich_characteristic(record)) return records def validate_characteristics(records: list[Characteristic]) -> list[Characteristic]: groups: dict[tuple[int, str], list[Characteristic]] = defaultdict(list) for record in records: enrich_characteristic(record) record.validation_issues = list(dict.fromkeys(record.validation_issues)) if not record.balloon_id: record.validation_issues.append("Balloon identifier was not read.") record.status = ReviewStatus.NEEDS_MAPPING if record.balloon_id.upper().startswith("UNASSIGNED"): record.validation_issues.append("Requirement is not mapped to a balloon.") record.status = ReviewStatus.NEEDS_MAPPING if not record.requirement: record.validation_issues.append("Requirement text is empty or unreadable.") if record.category in { Category.LINEAR_DIMENSION, Category.DIAMETER, Category.RADIUS, Category.ANGLE, } and not record.nominal: record.validation_issues.append("Nominal value requires confirmation.") if not record.source_text: record.validation_issues.append("No verbatim source text was preserved.") # AI results never become approved merely because confidence is high. if record.status == ReviewStatus.VERIFIED: record.status = ReviewStatus.NEEDS_REVIEW groups[(record.page, record.balloon_id)].append(record) for grouped in groups.values(): unique_requirements = { _key(record.requirement) for record in grouped if record.requirement.strip() } if len(unique_requirements) > 1: for record in grouped: record.validation_issues.append( "Conflicting requirements were extracted for the same balloon." ) return records def _extract_nominal(text: str) -> str | None: patterns = [ r"[⌀Ø∅]\s*([+-]?\d+(?:\.\d+)?)", r"(?:^|\s)R\s*([+-]?\d+(?:\.\d+)?)", r"(? None: if item.upper_tolerance or item.lower_tolerance: return bilateral = re.search(r"±\s*(\d+(?:\.\d+)?)", text) if bilateral: item.upper_tolerance = bilateral.group(1) item.lower_tolerance = f"-{bilateral.group(1)}" return unilateral = re.search( r"\+\s*(\d+(?:\.\d+)?)\s*(?:/|\s)\s*-\s*(\d+(?:\.\d+)?)", text, ) if unilateral: item.upper_tolerance = unilateral.group(1) item.lower_tolerance = f"-{unilateral.group(2)}" def _calculate_limits(item: Characteristic) -> None: if item.upper_limit or item.lower_limit: return nominal = _decimal(item.nominal) upper = _decimal(item.upper_tolerance) lower = _decimal(item.lower_tolerance) if nominal is None or upper is None or lower is None: return item.upper_limit = _decimal_text(nominal + upper) item.lower_limit = _decimal_text(nominal + lower) def _decimal(value: Any) -> Decimal | None: if value in (None, ""): return None text = str(value).strip().replace("+", "") if not re.fullmatch(r"-?\d+(?:\.\d+)?", text): return None try: return Decimal(text) except InvalidOperation: return None def _decimal_text(value: Decimal) -> str: return format(value.normalize(), "f") def _key(value: str) -> str: return re.sub(r"\W+", "", value.casefold())