354 lines
16 KiB
Python
354 lines
16 KiB
Python
"""
|
|
통합 자재 분류 시스템
|
|
메모리에 정의된 키워드 우선순위 체계를 적용
|
|
"""
|
|
|
|
import re
|
|
from typing import Dict, List, Optional, Tuple
|
|
from .fitting_classifier import classify_fitting
|
|
|
|
# Level 1: 명확한 타입 키워드 (최우선)
|
|
LEVEL1_TYPE_KEYWORDS = {
|
|
"BOLT": ["FLANGE BOLT", "U-BOLT", "U BOLT", "BOLT", "STUD", "NUT", "SCREW", "WASHER", "볼트", "너트", "스터드", "나사", "와셔", "유볼트"],
|
|
"VALVE": ["VALVE", "GATE", "BALL", "GLOBE", "CHECK", "BUTTERFLY", "NEEDLE", "RELIEF", "SIGHT GLASS", "STRAINER", "밸브", "게이트", "볼", "글로브", "체크", "버터플라이", "니들", "릴리프", "사이트글라스", "스트레이너"],
|
|
"FLANGE": ["FLG", "FLANGE", "플랜지", "프랜지", "ORIFICE", "SPECTACLE", "PADDLE", "SPACER", "BLIND", "REDUCING FLANGE", "RED FLANGE"],
|
|
"PIPE": ["PIPE", "TUBE", "파이프", "배관", "SMLS", "SEAMLESS"],
|
|
"FITTING": [
|
|
"SOCK-O-LET", "WELD-O-LET", "ELL-O-LET", "THREAD-O-LET", "ELB-O-LET", "NIP-O-LET", "COUP-O-LET",
|
|
"SOCKOLET", "WELDOLET", "ELLOLET", "THREADOLET", "ELBOLET", "NIPOLET", "COUPOLET", "OLET",
|
|
"ELBOW", "ELL", "TEE", "REDUCER", "CAP", "COUPLING", "NIPPLE", "SWAGE", "PLUG",
|
|
"엘보", "티", "리듀서", "캡", "니플", "커플링", "플러그", "CONC", "ECC",
|
|
"SWAGELOK", "UNION", "CONNECTOR", "FERRULE", "NUT & FERRULE", "MALE CONNECTOR", "FEMALE CONNECTOR"
|
|
],
|
|
"GASKET": ["GASKET", "GASK", "가스켓", "SWG", "SPIRAL"],
|
|
"INSTRUMENT": ["GAUGE", "TRANSMITTER", "SENSOR", "THERMOMETER", "계기", "게이지", "트랜스미터", "센서"],
|
|
"SUPPORT": ["URETHANE BLOCK", "URETHANE", "BLOCK SHOE", "CLAMP", "SUPPORT", "HANGER", "SPRING", "우레탄", "블록", "클램프", "서포트", "행거", "스프링"],
|
|
"PLATE": ["PLATE", "PL", "CHECKER PLATE", "판재", "철판"],
|
|
"STRUCTURAL": ["H-BEAM", "BEAM", "ANGLE", "CHANNEL", "H-SECTION", "I-BEAM", "형강", "앵글", "채널"]
|
|
}
|
|
|
|
# Level 2: 서브타입 키워드 (구체화)
|
|
LEVEL2_SUBTYPE_KEYWORDS = {
|
|
"VALVE": {
|
|
"GATE": ["GATE VALVE", "GATE", "게이트 밸브"],
|
|
"BALL": ["BALL VALVE", "BALL", "볼 밸브"],
|
|
"GLOBE": ["GLOBE VALVE", "GLOBE", "글로브 밸브"],
|
|
"CHECK": ["CHECK VALVE", "CHECK", "체크 밸브", "역지 밸브"]
|
|
},
|
|
"FLANGE": {
|
|
"WELD_NECK": ["WELD NECK", "WN", "웰드넥"],
|
|
"SLIP_ON": ["SLIP ON", "SO", "슬립온"],
|
|
"BLIND": ["BLIND", "BL", "막음", "차단"],
|
|
"SOCKET_WELD": ["SOCKET WELD", "소켓웰드"]
|
|
},
|
|
"BOLT": {
|
|
"HEX_BOLT": ["HEX BOLT", "HEXAGON", "육각 볼트"],
|
|
"STUD_BOLT": ["STUD BOLT", "STUD", "스터드 볼트"],
|
|
"U_BOLT": ["U-BOLT", "U BOLT", "유볼트"]
|
|
},
|
|
"SUPPORT": {
|
|
"URETHANE_BLOCK": ["URETHANE BLOCK", "BLOCK SHOE", "우레탄 블록"],
|
|
"CLAMP": ["CLAMP", "클램프"],
|
|
"HANGER": ["HANGER", "SUPPORT", "행거", "서포트"],
|
|
"SPRING": ["SPRING", "스프링"]
|
|
}
|
|
}
|
|
|
|
# Level 3: 연결/압력 키워드 (공용)
|
|
LEVEL3_CONNECTION_KEYWORDS = {
|
|
"SW": ["SW", "SOCKET WELD", "소켓웰드"],
|
|
"THD": ["THD", "THREADED", "NPT", "나사"],
|
|
"FL": ["FL", "FLANGED", "플랜지형"],
|
|
"BW": ["BW", "BUTT WELD", "맞대기용접"]
|
|
}
|
|
|
|
LEVEL3_PRESSURE_KEYWORDS = ["150LB", "300LB", "600LB", "900LB", "1500LB", "2500LB", "3000LB", "6000LB"]
|
|
|
|
# Level 4: 재질 키워드 (최후 판단)
|
|
LEVEL4_MATERIAL_KEYWORDS = {
|
|
"PIPE": ["A106", "A333", "A312", "A53"],
|
|
"FITTING": ["A234", "A403", "A420"],
|
|
"FLANGE": ["A182", "A350"], # A105 제거 (범용 재질로 이동)
|
|
"VALVE": ["A216", "A217", "A351", "A352"],
|
|
"BOLT": ["A193", "A194", "A320", "A325", "A490"]
|
|
}
|
|
|
|
# 범용 재질 (여러 타입에 사용 가능)
|
|
GENERIC_MATERIALS = {
|
|
"A105": ["VALVE", "FLANGE", "FITTING"], # 우선순위 순서
|
|
"316": ["VALVE", "FLANGE", "FITTING", "PIPE", "BOLT"],
|
|
"304": ["VALVE", "FLANGE", "FITTING", "PIPE", "BOLT"]
|
|
}
|
|
|
|
def classify_material_integrated(description: str, main_nom: str = "",
|
|
red_nom: str = "", length: float = None) -> Dict:
|
|
"""
|
|
통합 자재 분류 함수
|
|
|
|
Args:
|
|
description: 자재 설명
|
|
main_nom: 주 사이즈
|
|
red_nom: 축소 사이즈 (플랜지/피팅용)
|
|
length: 길이 (파이프용)
|
|
|
|
Returns:
|
|
분류 결과 딕셔너리
|
|
"""
|
|
|
|
desc_upper = description.upper()
|
|
|
|
# 최우선: SPECIAL 키워드 확인 (도면 업로드가 필요한 특수 자재)
|
|
# SPECIAL이 포함된 경우 (단, SPECIFICATION은 제외)
|
|
if 'SPECIAL' in desc_upper and 'SPECIFICATION' not in desc_upper:
|
|
return {
|
|
"category": "SPECIAL",
|
|
"confidence": 1.0,
|
|
"evidence": ["SPECIAL_KEYWORD"],
|
|
"classification_level": "LEVEL0_SPECIAL",
|
|
"reason": "SPECIAL 키워드 발견"
|
|
}
|
|
|
|
# 스페셜 관련 한글 키워드
|
|
if '스페셜' in desc_upper or 'SPL' in desc_upper:
|
|
return {
|
|
"category": "SPECIAL",
|
|
"confidence": 1.0,
|
|
"evidence": ["SPECIAL_KEYWORD"],
|
|
"classification_level": "LEVEL0_SPECIAL",
|
|
"reason": "스페셜 키워드 발견"
|
|
}
|
|
|
|
|
|
# VALVE 카테고리 우선 확인 (SIGHT GLASS, STRAINER)
|
|
if ('SIGHT GLASS' in desc_upper or 'STRAINER' in desc_upper or
|
|
'사이트글라스' in desc_upper or '스트레이너' in desc_upper):
|
|
return {
|
|
"category": "VALVE",
|
|
"confidence": 1.0,
|
|
"evidence": ["VALVE_SPECIAL_KEYWORD"],
|
|
"classification_level": "LEVEL0_VALVE",
|
|
"reason": "SIGHT GLASS 또는 STRAINER 키워드 발견"
|
|
}
|
|
|
|
# SUPPORT 카테고리 우선 확인 (BOLT 카테고리보다 먼저)
|
|
# U-BOLT, CLAMP, URETHANE BLOCK 등
|
|
if ('U-BOLT' in desc_upper or 'U BOLT' in desc_upper or '유볼트' in desc_upper or
|
|
'URETHANE BLOCK' in desc_upper or 'BLOCK SHOE' in desc_upper or '우레탄' in desc_upper or
|
|
'CLAMP' in desc_upper or '클램프' in desc_upper or 'PIPE CLAMP' in desc_upper):
|
|
return {
|
|
"category": "SUPPORT",
|
|
"confidence": 1.0,
|
|
"evidence": ["SUPPORT_SYSTEM_KEYWORD"],
|
|
"classification_level": "LEVEL0_SUPPORT",
|
|
"reason": "SUPPORT 시스템 키워드 발견"
|
|
}
|
|
|
|
# 쉼표로 구분된 각 부분을 별도로 체크 (예: "NIPPLE, SMLS, SCH 80")
|
|
desc_parts = [part.strip() for part in desc_upper.split(',')]
|
|
|
|
# 1단계: Level 1 키워드로 타입 식별
|
|
detected_types = []
|
|
|
|
# 특별 우선순위: REDUCING FLANGE 먼저 확인 (강화된 로직)
|
|
reducing_flange_patterns = [
|
|
"REDUCING FLANGE", "RED FLANGE", "REDUCER FLANGE",
|
|
"REDUCING FLG", "RED FLG", "REDUCER FLG"
|
|
]
|
|
|
|
# FLANGE와 REDUCING/RED/REDUCER가 함께 있는 경우도 확인
|
|
has_flange = any(flange_word in desc_upper for flange_word in ["FLANGE", "FLG"])
|
|
has_reducing = any(red_word in desc_upper for red_word in ["REDUCING", "RED", "REDUCER"])
|
|
|
|
# 직접 패턴 매칭 또는 FLANGE + REDUCING 조합
|
|
reducing_flange_detected = False
|
|
for pattern in reducing_flange_patterns:
|
|
if pattern in desc_upper:
|
|
detected_types.append(("FLANGE", "REDUCING FLANGE"))
|
|
reducing_flange_detected = True
|
|
break
|
|
|
|
# FLANGE와 REDUCING이 모두 있으면 REDUCING FLANGE로 분류
|
|
if not reducing_flange_detected and has_flange and has_reducing:
|
|
detected_types.append(("FLANGE", "REDUCING FLANGE"))
|
|
reducing_flange_detected = True
|
|
|
|
# REDUCING FLANGE가 감지되지 않은 경우에만 일반 키워드 검사
|
|
if not reducing_flange_detected:
|
|
for material_type, keywords in LEVEL1_TYPE_KEYWORDS.items():
|
|
type_found = False
|
|
# 긴 키워드부터 확인 (FLANGE BOLT가 FLANGE보다 먼저 매칭되도록)
|
|
sorted_keywords = sorted(keywords, key=len, reverse=True)
|
|
for keyword in sorted_keywords:
|
|
# [강화된 로직] 짧은 키워드나 중의적 키워드에 대한 엄격한 검사
|
|
is_strict_match = True
|
|
|
|
# 1. "PL" 키워드 검사 (PLATE)
|
|
if keyword == "PL":
|
|
# 단독 단어이거나 숫자 뒤에 붙는 경우만 허용 (예: 10PL, 10 PL)
|
|
# COUPLING, NIPPLE, PLUG 등에 포함된 PL은 제외
|
|
pl_pattern = r'(\b|\d)PL\b'
|
|
if not re.search(pl_pattern, desc_upper):
|
|
is_strict_match = False
|
|
|
|
# 2. "ANGLE" 키워드 검사 (STRUCTURAL)
|
|
elif keyword == "ANGLE" or keyword == "앵글":
|
|
# VALVE와 함께 쓰이면 제외 (ANGLE VALVE)
|
|
if "VALVE" in desc_upper or "밸브" in desc_upper:
|
|
is_strict_match = False
|
|
|
|
# 3. "UNION" 키워드 검사 (FITTING)
|
|
elif keyword == "UNION":
|
|
# 계장용인지 파이프용인지 구분은 fitting_classifier에서 하되,
|
|
# 여기서는 일단 FITTING으로 잡히도록 둠.
|
|
pass
|
|
|
|
# 4. "BEAM" 키워드 검사 (STRUCTURAL)
|
|
elif keyword == "BEAM":
|
|
# "BEAM CLAMP" 같은 경우 SUPPORT로 가야 함 (SUPPORT가 우선순위 높으므로 괜찮음)
|
|
pass
|
|
|
|
if not is_strict_match:
|
|
continue
|
|
|
|
# 전체 문자열에서 찾기
|
|
if keyword in desc_upper:
|
|
detected_types.append((material_type, keyword))
|
|
type_found = True
|
|
break
|
|
# 각 부분에서도 정확히 매칭되는지 확인
|
|
for part in desc_parts:
|
|
if keyword == part or keyword in part:
|
|
detected_types.append((material_type, keyword))
|
|
type_found = True
|
|
break
|
|
if type_found:
|
|
break
|
|
|
|
# 2단계: 복수 타입 감지 시 Level 2로 구체화
|
|
if len(detected_types) > 1:
|
|
# Level 2 키워드로 우선순위 결정
|
|
for material_type, subtype_dict in LEVEL2_SUBTYPE_KEYWORDS.items():
|
|
for subtype, keywords in subtype_dict.items():
|
|
for keyword in keywords:
|
|
if keyword in desc_upper:
|
|
return {
|
|
"category": material_type,
|
|
"confidence": 0.95,
|
|
"evidence": [f"L1_KEYWORD: {detected_types}", f"L2_KEYWORD: {keyword}"],
|
|
"classification_level": "LEVEL2"
|
|
}
|
|
|
|
# Level 2 키워드가 없으면 우선순위로 결정
|
|
# BOLT > SUPPORT > FITTING > VALVE > FLANGE > PIPE (볼트 우선, 더 구체적인 것 우선)
|
|
type_priority = ["BOLT", "SUPPORT", "FITTING", "VALVE", "FLANGE", "PIPE", "GASKET", "INSTRUMENT"]
|
|
for priority_type in type_priority:
|
|
for detected_type, keyword in detected_types:
|
|
if detected_type == priority_type:
|
|
return {
|
|
"category": priority_type,
|
|
"confidence": 0.85,
|
|
"evidence": [f"L1_MULTI_TYPE: {detected_types}", f"PRIORITY: {priority_type}"],
|
|
"classification_level": "LEVEL1_PRIORITY"
|
|
}
|
|
|
|
# 3단계: 단일 타입 확정 또는 Level 3/4로 판단
|
|
if len(detected_types) == 1:
|
|
material_type = detected_types[0][0]
|
|
|
|
# FITTING으로 분류된 경우 상세 분류기 호출
|
|
if material_type == "FITTING":
|
|
try:
|
|
detailed_result = classify_fitting("", description, main_nom, red_nom, length)
|
|
# 상세 분류 결과가 있으면 사용, 없으면 기본 FITTING 반환
|
|
if detailed_result and detailed_result.get("category"):
|
|
return detailed_result
|
|
except Exception as e:
|
|
# 상세 분류 실패 시 기본 FITTING으로 처리
|
|
pass
|
|
|
|
return {
|
|
"category": material_type,
|
|
"confidence": 0.9,
|
|
"evidence": [f"L1_KEYWORD: {detected_types[0][1]}"],
|
|
"classification_level": "LEVEL1"
|
|
}
|
|
|
|
# 4단계: Level 1 없으면 재질 기반 분류
|
|
if not detected_types:
|
|
# 전용 재질 확인
|
|
for material_type, materials in LEVEL4_MATERIAL_KEYWORDS.items():
|
|
for material in materials:
|
|
if material in desc_upper:
|
|
# 볼트 재질(A193, A194)은 다른 키워드가 있는지 확인
|
|
if material_type == "BOLT":
|
|
# 다른 타입 키워드가 있으면 볼트로 분류하지 않음
|
|
other_type_found = False
|
|
for other_type, keywords in LEVEL1_TYPE_KEYWORDS.items():
|
|
if other_type != "BOLT":
|
|
for keyword in keywords:
|
|
if keyword in desc_upper:
|
|
other_type_found = True
|
|
break
|
|
if other_type_found:
|
|
break
|
|
|
|
if other_type_found:
|
|
continue # 볼트로 분류하지 않음
|
|
|
|
# FITTING으로 분류된 경우 상세 분류기 호출
|
|
if material_type == "FITTING":
|
|
try:
|
|
detailed_result = classify_fitting("", description, main_nom, red_nom, length)
|
|
if detailed_result and detailed_result.get("category"):
|
|
return detailed_result
|
|
except Exception as e:
|
|
pass
|
|
|
|
return {
|
|
"category": material_type,
|
|
"confidence": 0.35, # 재질만으로 분류 시 낮은 신뢰도
|
|
"evidence": [f"L4_MATERIAL: {material}"],
|
|
"classification_level": "LEVEL4"
|
|
}
|
|
|
|
# 범용 재질 확인
|
|
for material, priority_types in GENERIC_MATERIALS.items():
|
|
if material in desc_upper:
|
|
# 우선순위에 따라 타입 결정
|
|
material_type = priority_types[0] # 첫 번째 우선순위
|
|
|
|
# FITTING으로 분류된 경우 상세 분류기 호출
|
|
if material_type == "FITTING":
|
|
try:
|
|
detailed_result = classify_fitting("", description, main_nom, red_nom, length)
|
|
if detailed_result and detailed_result.get("category"):
|
|
return detailed_result
|
|
except Exception as e:
|
|
pass
|
|
|
|
return {
|
|
"category": material_type,
|
|
"confidence": 0.3,
|
|
"evidence": [f"GENERIC_MATERIAL: {material}"],
|
|
"classification_level": "LEVEL4_GENERIC"
|
|
}
|
|
|
|
# 분류 실패
|
|
return {
|
|
"category": "UNCLASSIFIED",
|
|
"confidence": 0.0,
|
|
"evidence": ["NO_CLASSIFICATION_POSSIBLE"],
|
|
"classification_level": "NONE"
|
|
}
|
|
|
|
def should_exclude_material(description: str) -> bool:
|
|
"""
|
|
제외 대상 자재인지 확인
|
|
"""
|
|
exclude_keywords = [
|
|
"DUMMY", "RESERVED", "SPARE", "DELETED", "CANCELED",
|
|
"더미", "예비", "삭제", "취소", "예약"
|
|
]
|
|
|
|
desc_upper = description.upper()
|
|
return any(keyword in desc_upper for keyword in exclude_keywords) |