"""Нормализация repair_state → каноничный enum для listings.repair_state. Целевой enum: needs_repair / standard / good / excellent. Используется во всех инgest-путях: avito_detail, cian SERP, cian_detail. Источники raw-значений: - Avito HTML: «Ремонт: евро» → avito_detail._REPAIR_MAP → «euro» → good - Cian SERP JSON: offer.decoration → «euro» / «cosmetic» / «without» / ... - Cian Detail JSON: offer.repairType → «cosmetic» / «design» / «no» / «euro» / ... Маппинг основан на _IMV_REPAIR_MAP из estimator.py (обратная форма): needs_repair→«required», standard→«cosmetic», good→«euro», excellent→«designer». Связь с estimator._REPAIR_COEF: needs_repair=0.94 / standard=1.00 / good=1.05 / excellent=1.10 """ from __future__ import annotations import logging import re logger = logging.getLogger(__name__) # Все известные raw-значения с источником в комментарии _RAW_TO_ENUM: dict[str, str] = { # ── needs_repair ──────────────────────────────────────────────────────────── "required": "needs_repair", # Avito: «требуется» / «без ремонта» (через _REPAIR_MAP) "without": "needs_repair", # Cian: без ремонта "no": "needs_repair", # Cian: нет ремонта (синоним without) "rough": "needs_repair", # Cian/общий: черновая отделка "prefine": "needs_repair", # Cian detail: предчистовая/whitebox (offer.decoration, #1791) # ── standard ──────────────────────────────────────────────────────────────── "cosmetic": "standard", # Avito/Cian: косметический # ── good ──────────────────────────────────────────────────────────────────── "euro": "good", # Avito/Cian: евро "fine": "good", # Cian: хорошая отделка (предчистовая / whitebox) # ── excellent ─────────────────────────────────────────────────────────────── "designer": "excellent", # Avito: дизайнерский "design": "excellent", # Cian: дизайнерский (camelCase вариант) } # Допустимые значения целевого enum — для pass-through защиты _VALID_ENUM: frozenset[str] = frozenset({"needs_repair", "standard", "good", "excellent"}) def normalize_repair_state(raw: str | None) -> str | None: """Преобразовать raw repair-значение в каноничный enum. Если raw уже является корректным enum-значением — вернуть as-is (идемпотентно). Если raw не распознан — вернуть None + залогировать WARNING один раз. Args: raw: сырое значение из парсера (e.g. «euro», «without», «design») или None. Returns: Одно из needs_repair / standard / good / excellent, либо None. """ if raw is None: return None # Pass-through: уже нормализованный enum (idempotency — нужно при ре-обработке) if raw in _VALID_ENUM: return raw # Cian отдаёт camelCase ('preFine', 'whiteBox') — ключи карты в нижнем регистре, # сравниваем case-insensitive, чтобы не плодить дубль-ключи на каждый регистр. result = _RAW_TO_ENUM.get(raw) or _RAW_TO_ENUM.get(raw.lower()) if result is None: logger.warning("repair_state_normalizer: unknown raw value %r — stored as NULL", raw) return result # ── Inference из текста описания (#622) ────────────────────────────────────── # Когда структурное поле repair отсутствует (avito ~1% / cian ~5% / yandex 0%), # извлекаем состояние из текста описания регэкспами по русским фразам. # # Порядок ВАЖЕН: проверяем от самого сильного сигнала к слабому # excellent → good → needs_repair → standard, # чтобы «после черновой сделали дизайнерский ремонт» дал excellent, а не # needs_repair. Первое совпадение по порядку выигрывает. # # Целевой enum выровнен по normalize_repair_state() — те же 4 значения. _TEXT_PATTERNS: tuple[tuple[re.Pattern[str], str], ...] = ( # ── excellent: дизайнерский / премиум / эксклюзивный ремонт ────────────── ( re.compile( r"дизайнерск(?:ий|ого|ом|ая)\s+ремонт" r"|дизайнерск(?:ая|ой)\s+отделк" r"|премиальн\w*\s+ремонт" r"|эксклюзивн\w*\s+ремонт" r"|авторск\w*\s+ремонт", re.IGNORECASE, ), "excellent", ), # ── good: евроремонт / отличный / свежий / современный ремонт ──────────── ( re.compile( r"евроремонт" r"|евро[\s-]?ремонт" r"|отличн\w*\s+ремонт" r"|свеж\w*\s+ремонт" r"|современн\w*\s+ремонт" r"|качественн\w*\s+ремонт" r"|ремонт\s+в\s+отличн", re.IGNORECASE, ), "good", ), # ── needs_repair: без отделки / черновая / требует ремонта / под ремонт ── ( re.compile( r"без\s+отделк" r"|без\s+ремонт" r"|черновая\s+отделк" r"|черновой\s+ремонт" r"|требует(?:ся)?\s+ремонт" r"|требует(?:ся)?\s+космет" r"|нужен\s+ремонт" r"|под\s+ремонт" r"|под\s+чистов\w+\s+отделк" r"|предчистов", re.IGNORECASE, ), "needs_repair", ), # ── standard: косметический / обычный / жилое состояние ───────────────── ( re.compile( r"косметическ\w*\s+ремонт" r"|косметическ\w*\s+отделк" r"|обычн\w*\s+ремонт" r"|жил(?:ое|ом)\s+состоян" r"|хорош\w*\s+состоян", re.IGNORECASE, ), "standard", ), ) def infer_repair_state_from_text(description: str | None) -> str | None: """Извлечь repair_state из текста описания объявления (#622). Fallback-путь для повышения покрытия listings.repair_state, когда структурное поле источника отсутствует. Проверяет паттерны в порядке убывания силы сигнала (excellent → good → needs_repair → standard); первое совпадение выигрывает. Args: description: текст описания объявления (avito/cian/yandex) или None. Returns: Одно из needs_repair / standard / good / excellent, либо None если ни один паттерн не сработал (НЕ фабрикуем — unknown остаётся NULL). """ if not description: return None for pattern, enum_value in _TEXT_PATTERNS: if pattern.search(description): return enum_value return None