From b5c26d2723442a2994d5ffb303e32f3ab9523959 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Fri, 29 May 2026 22:21:29 +0000 Subject: [PATCH] fix(tradein): harden same-building anchor address normalizer for prod formats (#677) (#679) Co-authored-by: bot-backend Co-committed-by: bot-backend --- tradein-mvp/backend/app/services/estimator.py | 139 ++++++++++++++---- .../tests/test_same_building_anchor.py | 106 +++++++++++++ 2 files changed, 216 insertions(+), 29 deletions(-) diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index 5add7cf3..52824378 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -855,54 +855,135 @@ def _fetch_dkp_corridor( # Street-alias map: ЕКБ-специфичные расхождения между golden/source-адресами и БД. # Golden «Ткачёва 13» = БД «Ткачей 13» — без алиаса 0 комплов для 5 business-кейсов # (Clever Park). Ключи/значения уже ё→е-нормализованы и lowercase. Расширяемо. +# ВАЖНО: применяется к ЦЕЛЬНОМУ street_core (после strip type-words), не к токену. _STREET_ALIAS_MAP: dict[str, str] = { "ткачева": "ткачей", # «ул. Ткачёва» (родит. падеж) ↔ БД «ул. Ткачей» } -# Токен номера дома: ведущее целое + опциональная литера (рус/лат) + опциональный -# корпус «/N». Примеры: «204Г» → (204, 'г'); «4/2» → (4, None); «48» → (48, None). -_HOUSE_TOKEN_RE = re.compile( - r"(?P\d+)\s*(?P[а-яёa-z])?(?:\s*[/-]\s*\d+)?", - flags=re.IGNORECASE | re.UNICODE, +# Street-type токены: канонизируем (drop type-слово, оставляем имя). Реальные prod- +# адреса ставят тип ДО или ПОСЛЕ имени («улица Ткачей» И «Олимпийская наб.») — поэтому +# дропаем тип ОТКУДА УГОДНО в строке, а не только ведущий keyword. Лемматизированы по +# точкам/окончаниям: матчим точное слово ИЛИ «.»-сокращение. +_STREET_TYPE_TOKENS: frozenset[str] = frozenset( + { + "улица", + "ул", + "у", + "переулок", + "пер", + "проспект", + "пр", + "прт", + "пркт", + "пр-кт", + "пр-т", + "проезд", + "бульвар", + "бр", + "б-р", + "набережная", + "наб", + "шоссе", + "ш", + "площадь", + "пл", + "тракт", + "аллея", + "тупик", + } ) +# Административные токены-маркеры: всё, что относится к городу/району/мкр — НЕ часть +# имени дома. Дропаем токен-маркер ВМЕСТЕ со следующим за ним словом-значением +# («р-н Октябрьский», «мкр Парковый», «г Екатеринбург»). Города ЕКБ-агломерации тоже +# чистим (стоят как ведущий токен «Екатеринбург,»/«Первоуральск,»). +_ADMIN_MARKER_TOKENS: frozenset[str] = frozenset( + {"рн", "р-н", "район", "мкр", "микрорайон", "г", "гор", "город", "обл", "область"} +) +_CITY_TOKENS: frozenset[str] = frozenset( + {"екатеринбург", "первоуральск", "березовский", "верхняя", "пышма", "среднеуральск", "россия"} +) + +# Литера корпуса, прилипшая к номеру: «16а», «204г», «57а» → base + letter (рус/лат). +_HOUSE_LETTER_RE = re.compile(r"^(?P\d+)\s*(?P[а-яa-z])?$", flags=re.UNICODE) +# Разбивка на токены: слова/числа, отбрасывая пунктуацию (',', '.', '·', '/', '-'). +_TOKEN_SPLIT_RE = re.compile(r"[\s,;·]+", flags=re.UNICODE) + def _normalize_building_key( address: str | None, ) -> tuple[str | None, int | None, str | None]: - """Нормализует адрес в ключ «того же дома»: (street_alias, base_house_no, letter). + """Нормализует адрес в robust-ключ «того же дома»: (street_core, base_no, letter). - - ё→е, lowercase, отрезаем «г. Екатеринбург»/«ул.»/«пр.»/… (reuse extract_street_name). - - base_house_no = ведущее целое house-токена («204Г»/«4/2»/«9/1» → 204/4/9). - - letter = опциональная литера корпуса (204**Г** → 'г'); корпус «/N» отбрасываем - (один дом). Литеры — РАЗНЫЕ дома в одном ЖК (8 Марта 204Г ≠ 204Д), поэтому - при наличии литеры у target матчим именно её (точнее, без ложного слипания). - - street прогоняется через _STREET_ALIAS_MAP (Ткачёва→Ткачей и т.д.). + Инвариантен к prod-форматам ЕКБ-вторички: + - ё→е, lowercase. + - отрезает город / «р-н …» / «мкр …» / «г. …» / «· …»-хвост (district-suffix). + - дропает street-type слова ОТКУДА УГОДНО (улица/ул./наб./набережная/пр./… — + тип может стоять ДО или ПОСЛЕ имени: «улица Ткачей» И «Олимпийская наб.»); + остаток alpha-токенов = street_core («бориса ельцина», «сакко и ванцетти»). + - base_no = первый числовой токен ПОСЛЕ имени улицы, толерантно к + «Ткачей, 13» = «Ткачей,13» = «Ткачей,д. 13» = «Ткачей дом 13». + - letter = прилипшая литера корпуса («16а»→'а', «204г»→'г'); «/N» и «кN» + (корпус) схлопываются к base (тот же дом). Литеры — РАЗНЫЕ дома (204г ≠ 204д). + - street_core прогоняется через _STREET_ALIAS_MAP (ткачева→ткачей). - Returns (street_norm, base_no, letter) — любой элемент None если не извлёкся. + Returns (street_core, base_no, letter) — любой элемент None если не извлёкся. Best-effort: при пустом адресе → (None, None, None). """ if not address: return None, None, None - norm = address.replace("ё", "е").replace("Ё", "Е") - street = extract_street_name(norm) - street_norm = street.strip().lower() if street else None - if street_norm: - street_norm = _STREET_ALIAS_MAP.get(street_norm, street_norm) + norm = address.replace("ё", "е").replace("Ё", "Е").lower() + # Отрезаем «· …»-хвост (Avito-формат «… · р-н Центр»): district всегда после «·». + norm = norm.split("·")[0] + # «д.»/«дом» перед номером → пробел (чтобы числовой токен встал отдельно). + norm = re.sub(r"\b(?:д\.?|дом)\s*(?=\d)", " ", norm, flags=re.UNICODE) - # Ищем house-токен ПОСЛЕ имени улицы (чтобы «8 Марта» не дало num=8 как дом). - tail = norm - if street: - idx = norm.lower().find(street.lower()) - if idx >= 0: - tail = norm[idx + len(street) :] + raw = [t for t in _TOKEN_SPLIT_RE.split(norm) if t] + + # 1. Вычищаем admin-маркеры (+следующее за ними значение) и города. + cleaned: list[str] = [] + skip_next = False + for tok in raw: + if skip_next: + skip_next = False + continue + bare = tok.rstrip(".") + if bare in _ADMIN_MARKER_TOKENS: + skip_next = True # дропаем и сам маркер, и следующее слово-значение + continue + if bare in _CITY_TOKENS: + continue + cleaned.append(tok) + + # 2. House-токен = ПОСЛЕДНИЙ токен, начинающийся с цифры. Берём последний (а не + # первый), чтобы числа ВНУТРИ имени улицы («8 Марта», «1905 года») не съелись + # как номер дома — настоящий номер всегда в хвосте, за именем. Хвост за номером + # (корпус «/N», «кN») игнорируем; всё остальное — токены улицы. base_no: int | None = None letter: str | None = None - m = _HOUSE_TOKEN_RE.search(tail) - if m: - base_no = int(m.group("num")) - letter = m.group("letter").lower() if m.group("letter") else None - return street_norm, base_no, letter + house_idx: int | None = None + for i, tok in enumerate(cleaned): + if tok[0].isdigit(): + head = re.split(r"[/\\]", tok, maxsplit=1)[0] # «4/2» → «4»; корпус отброшен + head = re.split(r"к\d", head, maxsplit=1)[0] # «105к1» → «105» + m = _HOUSE_LETTER_RE.match(head) + if m: + base_no = int(m.group("num")) + letter = m.group("letter") or None + house_idx = i + + # 3. street_core = токены до номера дома, минус type-слова (улица/наб./пр./…) и + # минус сам house-токен. Числовые префиксы имени («8 марта») сохраняем. + street_tokens = [ + tok + for i, tok in enumerate(cleaned) + if i != house_idx and tok.rstrip(".") not in _STREET_TYPE_TOKENS + ] + street_core = " ".join(street_tokens).strip() or None + if street_core: + street_core = _STREET_ALIAS_MAP.get(street_core, street_core) + + return street_core, base_no, letter def _fetch_anchor_comps( diff --git a/tradein-mvp/backend/tests/test_same_building_anchor.py b/tradein-mvp/backend/tests/test_same_building_anchor.py index 6154fa64..c3052372 100644 --- a/tradein-mvp/backend/tests/test_same_building_anchor.py +++ b/tradein-mvp/backend/tests/test_same_building_anchor.py @@ -59,6 +59,112 @@ def test_normalize_empty_address() -> None: assert _normalize_building_key("") == (None, None, None) +# ── #677 hardening: real prod address-format variants → same key ───────────── + + +def test_normalize_tkachei13_all_db_variants_same_key() -> None: + """Все prod-форматы «Ткачей 13» (запятая/«д.»/город/·-хвост/р-н/мкр) → ('ткачей', 13).""" + variants = [ + "Екатеринбург, ул. Ткачёва, 13", # golden alias + ё + "Екатеринбург, улица Ткачей, 13", + "ул. Ткачей,13", # no space after comma + "ул. Ткачей,д. 13", # «д.» variant + "улица Ткачей, 13 · р-н Октябрьский", # ·-tail district + "р-н Октябрьский, мкр. Парковый, улица Ткачей, 13", # district + mkr prefix + ] + for v in variants: + assert _normalize_building_key(v) == ("ткачей", 13, None), v + + +def test_normalize_house_13_not_130_or_131() -> None: + """Дом 13 не должен совпасть с соседями 130/131 (разные base_no).""" + assert _normalize_building_key("улица Ткачей, 13")[1] == 13 + assert _normalize_building_key("улица Ткачей, 130")[1] == 130 + assert _normalize_building_key("улица Ткачей, 131")[1] == 131 + + +def test_normalize_naberezhnaya_type_word_either_side() -> None: + """Type-слово до ИЛИ после имени → один street_core «олимпийская»; наб.↔набережная.""" + keys = { + _normalize_building_key("Олимпийская наб.,13"), + _normalize_building_key("набережная Олимпийская, 13"), + _normalize_building_key("Екатеринбург, Олимпийская набережная, 13"), + _normalize_building_key("р-н Центр, набережная Олимпийская, 13"), + } + assert keys == {("олимпийская", 13, None)} + + +def test_normalize_borisa_eltsina_multiword_street() -> None: + """Многословное имя «Бориса Ельцина» сохраняется целиком; «д. 6»/город/·-хвост → 6.""" + for v in ( + "ул. Бориса Ельцина,д. 6", + "ул. Бориса Ельцина,6", + "Екатеринбург, улица Бориса Ельцина, 6", + "Бориса Ельцина, 6", + "улица Бориса Ельцина, 6 · р-н Центр", + ): + assert _normalize_building_key(v) == ("бориса ельцина", 6, None), v + + +def test_normalize_sakko_multiword_with_conjunction() -> None: + """«Сакко и Ванцетти» (с союзом) → стабильный core; 99 ≠ 100/105к1 (разные дома).""" + assert _normalize_building_key("ул. Сакко и Ванцетти,99") == ("сакко и ванцетти", 99, None) + assert _normalize_building_key("р-н Центр, улица Сакко и Ванцетти, 99") == ( + "сакко и ванцетти", + 99, + None, + ) + assert _normalize_building_key("ул. Сакко и Ванцетти,100")[1] == 100 # separate building + assert _normalize_building_key("р-н Центр, улица Сакко и Ванцетти, 105к1") == ( + "сакко и ванцетти", + 105, # «105к1» корпус схлопнут к base + None, + ) + + +def test_normalize_fevralskoy_revolyutsii_no_space_house() -> None: + """«ул. Февральской Революции,21» (genitive multiword, no-space house) → core+21.""" + assert _normalize_building_key("ул. Февральской Революции,21") == ( + "февральской революции", + 21, + None, + ) + + +def test_normalize_8_marta_numeric_street_prefix() -> None: + """Числовой префикс имени («8 Марта») НЕ съедается как номер дома — номер в хвосте.""" + assert _normalize_building_key("Екатеринбург, улица 8 Марта, 204Г") == ("8 марта", 204, "г") + # без литеры + assert _normalize_building_key("улица 8 Марта, 50") == ("8 марта", 50, None) + + +def test_normalize_corpus_letter_distinct_from_plain() -> None: + """Литера корпуса — РАЗНЫЕ дома: 16А (letter 'а') ≠ 16 (None).""" + assert _normalize_building_key("Екатеринбург, улица Ткачей, 16А") == ("ткачей", 16, "а") + assert _normalize_building_key("улица Ткачей, 16") == ("ткачей", 16, None) + + +def test_normalize_slash_corpus_collapses() -> None: + """Корпус «/N» схлопывается к base (тот же дом): 9/1 → 9, 4/2 → 4, 19/2 → 19.""" + assert _normalize_building_key("Олимпийская набережная, 9/1")[1] == 9 + assert _normalize_building_key("Екатеринбург, ул. Мраморская, 4/2")[1] == 4 + assert _normalize_building_key("улица Ткачей, 19/2")[1] == 19 + + +def test_normalize_alias_applied_to_full_core_with_yo() -> None: + """ё→е применяется ДО alias; alias матчит целый core (ткачёва→ткачева→ткачей).""" + assert _normalize_building_key("ул. Ткачёва, 13")[0] == "ткачей" + + +def test_normalize_no_house_number_returns_none_base() -> None: + """Адрес без номера дома («р-н Центр, улица Бориса Ельцина») → base_no None (no match).""" + assert _normalize_building_key("р-н Центр, улица Бориса Ельцина") == ( + "бориса ельцина", + None, + None, + ) + + # ── Pure anchor compute ─────────────────────────────────────────────────────