From 8de8daef0115948c99dba3c600dec01410236707 Mon Sep 17 00:00:00 2001 From: lekss361 Date: Sun, 24 May 2026 23:54:13 +0300 Subject: [PATCH] fix(tradein): extract_street_name handles Nominatim reverse format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live bug: geocoder Nominatim возвращает адрес в REVERSE формате "80, улица 8 Марта, район, ..., Россия" — старый regex брал всё после "8 Марта" вместо stop'а перед первой запятой → ILIKE '%%' возвращал 0 matches на проде. Fix: новый _STREET_KW_RE ищет keyword (ул./улица/пр./...) затем _STREET_NAME_RE капчит 1-3 слова имени улицы со stop'ом на comma или house number. Работает с обоими форматами: forward: "Екатеринбург, ул. Космонавтов, 50" → "Космонавтов" reverse: "80, улица 8 Марта, район, ..." → "8 Марта" Bonus fallback: первый capitalized токен (для "Большая Конюшенная, 25" без keyword). Tests: 10 parametrized cases в test_street_deals_endpoint.py. --- tradein-mvp/backend/app/services/estimator.py | 79 ++++++++++++------- .../tests/test_street_deals_endpoint.py | 33 ++++++++ 2 files changed, 83 insertions(+), 29 deletions(-) diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index 6e61efac..026a292b 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -1112,49 +1112,70 @@ def _extract_short_addr(full_address: str | None) -> str | None: return s.strip(" ,.") or None -# Strips trailing house-number and any subsequent parts from a short address. -_TRAILING_HOUSE_RE = re.compile( - r",\s*\d+.*$", - flags=re.UNICODE, +# Ищет keyword типа улицы (ул./улица/пр./проспект/...) в адресе. +# Работает для FORWARD и REVERSE форматов Nominatim. +_STREET_KW_RE = re.compile( + r"(? str | None: - """Извлекает только название улицы (без типа и номера дома) из полного адреса. - - Используется для запросов в open dataset Росреестра, где адреса агрегированы - до уровня улицы (без номера дома). + """Извлекает чистое имя улицы из адреса в FORWARD или REVERSE формате. Примеры: - "Свердловская обл., г. Екатеринбург, ул. Космонавтов, 12" → "Космонавтов" - "Екатеринбург, проспект Ленина, 50" → "Ленина" - "Екатеринбург, ул. 8 Марта, 18" → "8 Марта" - "г. Екатеринбург, пер. Красный, 4" → "Красный" + "Екатеринбург, ул. Космонавтов, 50" → "Космонавтов" + "80, улица 8 Марта, Артек, ..., Россия" → "8 Марта" + "проспект Ленина 50" → "Ленина" + "Россия, Екатеринбург, ул. Малышева, 1" → "Малышева" + "ул. Большая Конюшенная, 25" → "Большая Конюшенная" + "" → None Алгоритм: - 1. _extract_short_addr → «ул. Космонавтов, 12» (или fallback строка). - 2. Отрезаем trailing «, 12...» (house number и всё после). - 3. Отрезаем leading street-type prefix («ул.», «проспект» и т.д.). - 4. None если ничего не осталось. + 1. Ищем street-keyword (ул/улица/пр/проспект/...) — case-insensitive. + 2. После keyword берём 1-3 слова до запятой или номера дома. + 3. Если keyword не нашёлся — пытаемся первый capitalized токен с + поиском до запятой или номера (fallback для адресов без keyword'а). + + Returns None если ничего не извлеклось. """ - short = _extract_short_addr(full_address) - if not short: + if not full_address or not full_address.strip(): return None - # Strip trailing house number: ", 12" / ", 12А" / ", 48/2" - without_house = _TRAILING_HOUSE_RE.sub("", short).strip(" ,.") + s = full_address.strip() - # Strip leading type prefix - street_name = _STREET_PREFIX_RE.sub("", without_house).strip() - return street_name or None + # 1. Keyword-based extraction (работает для обоих форматов: forward и reverse) + m = _STREET_KW_RE.search(s) + if m: + rest = s[m.end():].lstrip() + nm = _STREET_NAME_RE.match(rest) + if nm: + return nm.group(1).strip() + + # 2. Fallback: нет keyword — пробуем первый capitalized токен + # Используется для "Большая Конюшенная, 25" без "ул." + nm = _STREET_NAME_RE.match(s) + if nm: + candidate = nm.group(1).strip() + # Отсеиваем очевидные административные слова + bad = {"Россия", "Москва", "Санкт-Петербург", "область", "район", "округ", "край"} + if not any(b in candidate for b in bad): + return candidate + + return None def _stratify_candidates(candidates: list[dict[str, Any]]) -> list[dict[str, Any]]: diff --git a/tradein-mvp/backend/tests/test_street_deals_endpoint.py b/tradein-mvp/backend/tests/test_street_deals_endpoint.py index ec6bfc11..e1bdbf6e 100644 --- a/tradein-mvp/backend/tests/test_street_deals_endpoint.py +++ b/tradein-mvp/backend/tests/test_street_deals_endpoint.py @@ -74,6 +74,39 @@ def test_extract_street_name_no_street_keyword() -> None: assert result is None or isinstance(result, str) +@pytest.mark.parametrize( + "address,expected", + [ + # Forward format (user-typed) + ("Екатеринбург, ул. Космонавтов, 50", "Космонавтов"), + ("Россия, Екатеринбург, ул. Малышева, 1", "Малышева"), + ("проспект Ленина 50", "Ленина"), + ("ул. Большая Конюшенная, 25", "Большая Конюшенная"), + # Reverse format (from Nominatim geocoder) + ( + "80, улица 8 Марта, Артек, Форум-Сити, Ленинский район, Екатеринбург," + " городской округ Екатеринбург, Свердловская область," + " Уральский федеральный округ, 640144, Россия", + "8 Марта", + ), + ( + "50, улица Космонавтов, Орджоникидзевский район, Екатеринбург, Россия", + "Космонавтов", + ), + # Edge cases + ("", None), + (None, None), + ("just plain text", None), + ("Москва", None), + ], +) +def test_extract_street_name_parametrized(address: str | None, expected: str | None) -> None: + """Parametrized cases covering forward, reverse (Nominatim), and edge cases.""" + from app.services.estimator import extract_street_name + + assert extract_street_name(address) == expected + + # ── Helpers ───────────────────────────────────────────────────────────────────