fix(tradein): extract_street_name handles Nominatim reverse format (#557)
All checks were successful
Deploy Trade-In / changes (push) Successful in 5s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-backend (push) Successful in 40s
Deploy Trade-In / deploy (push) Successful in 38s

This commit is contained in:
lekss361 2026-05-24 21:03:42 +00:00
parent 4d1c409e18
commit 8c540ecec6
2 changed files with 83 additions and 29 deletions

View file

@ -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"(?<![А-Яа-яёЁa-zA-Z])"
r"(?:ул\.|улица|пр\.|пр-т|проспект|пер\.|переулок|"
r"б-р|бульвар|ш\.|шоссе|наб\.|набережная|проезд|тракт|"
r"пл\.|площадь|мкр\.|мкр|микрорайон)"
r"\s+",
flags=re.IGNORECASE | re.UNICODE,
)
# Strips leading street-type prefix (ул., проспект, пер., etc.) from a street token.
_STREET_PREFIX_RE = re.compile(
r"^(?:ул\.|улица|пр\.|пр-т|проспект|пер\.|переулок|"
r"б-р|бульвар|ш\.|шоссе|наб\.|набережная|проезд|тракт|пл\.|площадь|"
r"мкр\.?|микрорайон)\s+",
flags=re.IGNORECASE | re.UNICODE,
# После keyword: 1-3 слова имени улицы со стопом на запятую или номер дома.
# Поддерживает "8 Марта" (цифра + слово) и "Большая Конюшенная" (несколько слов).
_STREET_NAME_RE = re.compile(
r"^([0-9]+\s+[А-Яа-яёЁ][А-Яа-яёЁ-]+"
r"|[А-Яа-яёЁ][А-Яа-яёЁ-]+(?:\s+[А-Яа-яёЁ][А-Яа-яёЁ-]+){0,2})"
r"(?=,|\s+(?:д\.?\s*)?\d|\s*$)",
flags=re.UNICODE,
)
def extract_street_name(full_address: str | None) -> 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]]:

View file

@ -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 ───────────────────────────────────────────────────────────────────