diff --git a/site/services/service_identity.py b/site/services/service_identity.py new file mode 100644 index 0000000..40a5075 --- /dev/null +++ b/site/services/service_identity.py @@ -0,0 +1,152 @@ +"""Детерминированная идентификация семейства, якорей и параметров услуги. + +Стабильный якорь экземпляра отделён от изменяемых параметров конфигурации: +изменение мощности, объёма ресурса или цены не должно само по себе создавать +новую услугу, тогда как разные стойки, облачные платформы и маршруты остаются +разными экземплярами. Неизвестные строки сохраняются как family `other`. +""" +import re + + +_HOMOGLYPHS = str.maketrans({ + "A": "А", "B": "В", "C": "С", "E": "Е", "H": "Н", "K": "К", + "M": "М", "O": "О", "P": "Р", "T": "Т", "X": "Х", "Y": "У", +}) +_RACK_RE = re.compile(r"\b\d[A-ZА-ЯЁ]\d[A-ZА-ЯЁ]\d{3}\b") +_NUMBER = r"(\d+(?:[.,]\d+)?)" + + +def _normalized(text): + """Нормализовать регистр, сохраняя различимые английские маркеры услуг.""" + return str(text or "").upper().replace("Ё", "Е") + + +def _decimal_text(value): + """Представить числовой фрагмент с точкой и без незначащих нулей.""" + return value.replace(",", ".").rstrip("0").rstrip(".") if "." in value or "," in value else value + + +def _first_capture(pattern, text): + """Извлечь первое числовое значение, сохранив точность исходной строки.""" + match = re.search(pattern, text, re.IGNORECASE) + return _decimal_text(match.group(1)) if match else None + + +def _service_family(text): + """Назначить наиболее специфичное семейство по доменным словам Nubes.""" + rules = ( + ("cloud_drive", r"VALO\s+CLOUD|ОБЛАЧН\w*\s+ДИСК"), + ("rack_cabinet", r"ШКАФ\s+(?:СЕРВЕРН\w*|\d+\s*U)"), + ("pdu", r"БЛОК\s+РОЗЕТОК|\bPDU\b"), + ("fiber_line", r"ВОЛС|ВОЛОКН|ЛИНИ\w*\s+СВЯЗИ"), + ("l2_channel", r"КАНАЛ\s+СВЯЗИ|\bL2\b|ТОЧКА\s*[-–—]?\s*ТОЧКА"), + ("network_port", r"АРЕНДА\s+ПОРТА|ПОРТ\s+В\s+КОММУТАТОРЕ|CROSS\s*[-–]?\s*CONNECT|КРОСС\s*[-–]?\s*СОЕДИН"), + ("ip_subnet", r"ПОДСЕТ|IPV[46]|IP\s*[-–]?\s*АДРЕС|/\s*(?:24|29|30)\b"), + ("internet", r"ИНТЕРНЕТ|ТРАФИК|DDOS"), + ("cloud_iaas", r"NEXT\s+GENERATION\s+CLOUD|ОБЛАЧН\w*|VCPU|VRAM|\bIAAS\b"), + ("waf", r"\bWAF\b|ЗАЩИТ\w*\s+ВЕБ"), + ("colocation_rack", r"СТОЙКО\s*[-–—]?\s*МЕСТ|COLOCATION|\b\d+\s*U\b"), + ) + for family, pattern in rules: + if re.search(pattern, text, re.IGNORECASE): + return family + return "other" + + +def _extract_anchors(text, family): + """Извлечь идентификаторы экземпляра, не перенося туда изменяемые параметры.""" + anchors = set(_RACK_RE.findall(text)) + if family == "fiber_line": + route = re.search( + r"(?:ОТ\s+)?(.{0,100}?)(?:\s+ДО\s+)(.{0,100})", + text, + re.IGNORECASE, + ) + if route: + anchors.add("ROUTE:" + re.sub(r"\s+", " ", route.group(1) + "->" + route.group(2)).strip()) + if family == "cloud_iaas": + platform = re.search(r"\b(?:AMD|INTEL)\s*\d+(?:[.,]\d+)?\s*ГГЦ|\bHIGHFREQ\b|\bSTD\b", text) + if platform: + anchors.add("PLATFORM:" + re.sub(r"\s+", "", platform.group(0))) + if family == "ip_subnet": + subnet = re.search(r"/\s*(\d{1,2})\b", text) + if subnet: + anchors.add("PREFIX:/" + subnet.group(1)) + if family == "network_port": + speed = re.search(r"\b\d+(?:[.,]\d+)?\s*(?:ГБИТ/С|МБИТ/С|GBPS|MBPS)\b", text) + if speed: + anchors.add("SPEED:" + re.sub(r"\s+", "", speed.group(0))) + return "|".join(sorted(anchors)) + + +def _extract_config(text): + """Извлечь известные изменяемые численные параметры как точные строки.""" + params = {} + patterns = { + "kw": rf"{_NUMBER}\s*КВТ\b", + "units_u": rf"(?:РАЗМЕР\s+СТОЙКИ\s*[:\-]?\s*)?(\d+)\s*U\b", + "vcpu": rf"(?:КОЛИЧЕСТВО\s*)?VCPU\s*[:\-]?\s*(\d+)", + "vram_gb": rf"(?:ОБЪ[ЕЕ]М\s*)?VRAM\s*[:\-]?\s*{_NUMBER}\s*ГБ", + "sata_gb": rf"SATA\s*[:\-]?\s*{_NUMBER}\s*ГБ", + "ssd_gb": rf"(?