From 45017d2535949de054697a8193480418dec76807 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 17 Sep 2026 13:50:31 +0500 Subject: [PATCH] =?UTF-8?q?fix(mera/estimator):=20=D0=B4=D0=B5=D0=B4=D1=83?= =?UTF-8?q?=D0=BF=20=D1=80=D0=B0=D0=B7=D0=BB=D0=B8=D1=87=D0=B0=D0=B5=D1=82?= =?UTF-8?q?=20=D0=BF=D0=BE=D1=80=D1=8F=D0=B4=D0=BA=D0=BE=D0=B2=D1=8B=D0=B5?= =?UTF-8?q?=20=D1=83=D0=BB=D0=B8=D1=86=D1=8B=20=C2=AB2-=D1=8F=20=D0=A1?= =?UTF-8?q?=D0=B8=D0=BD=D0=B8=D1=87=D0=BA=D0=B8=D0=BD=D0=B0=C2=BB=20=D0=B2?= =?UTF-8?q?=D0=BE=20=D0=B2=D1=81=D0=B5=D1=85=20=D1=82=D1=80=D1=91=D1=85=20?= =?UTF-8?q?=D1=84=D0=BE=D1=80=D0=BC=D0=B0=D1=82=D0=B0=D1=85=20(#2291,=20?= =?UTF-8?q?=D0=BA=D0=BB=D0=B0=D1=81=D1=81=204)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Что было. После «40-летия Комсомола» остались однобуквенные порядковые: регексы требовали после «N-» минимум две буквы. Одна улица в трёх форматах разбиралась по-разному: cian «улица 2-я Синичкина» давал ('', ''), avito/yandex «2-я ул. Синичкина» и domklik «2-я Синичкина улица» теряли номер ('синичкина'). Потеря номера — это ложная склейка: «9-я Парковая, 5» и «12-я Парковая, 5» получали один ключ ('парковая', '5'). Что сделано. В обоих регексах названия улицы добавлен необязательный порядковый «N-x » перед (нумерованным) именем; формат «номер тип имя» переставляется в «тип номер имя» до разбора. Номер дома с дефисом не тронут. Замер на проде (tradein-postgres, SELECT, 17.09): 12 848 разных адресов с шаблоном «цифра-дефис/пробел-буква», 29 959 объявлений. Разбор изменился у 8 713 объявлений; ни один адрес не потерял токен. Межисточниковых групп (улица, дом) 1305 -> 1473. Из 81 прежней группы, которая распалась, 60 — разные порядковые улицы, 3 — ложный mkr-ключ, 18 — голая и порядковая улица в разных городах (одна — источник сам потерял номер). Гейт бэктеста без изменений baseline. Refs #2291 Co-Authored-By: Claude Opus 5 --- tradein-mvp/backend/app/services/estimator.py | 20 ++++-- .../test_estimator_dedup_cross_source_2087.py | 62 +++++++++++++++++++ 2 files changed, 78 insertions(+), 4 deletions(-) diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index 3f07528a..394fc3a8 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -7330,15 +7330,17 @@ _DEDUP_STREET_TYPE_ALT = "|".join( # «<тип>[.] <название>»: название = опциональная ведущая цифро-группа + 1-3 # буквенных слова, до запятой/цифры. Ведущая цифра нужна для нумерованных улиц # («8 Марта», «40 лет Октября», «22 Партсъезда»; #2291 класс 4 — и через дефис: -# «40-летия Комсомола», «22-го Партсъезда»); она разрешена ТОЛЬКО перед -# буквенным словом (?:\d+(?:\s+|-))?[а-яё]…, поэтому НОМЕР ДОМА (число ПОСЛЕ названия, +# «40-летия Комсомола», «22-го Партсъезда»; однобуквенный порядковый «2-я +# Новосибирская», «3-й Конный», в т.ч. перед нумерованным именем «2-й переулок +# 1 Мая»); она разрешена ТОЛЬКО перед буквенным словом +# (?:\d+-[а-яё]\s+)?(?:\d+(?:\s+|-))?[а-яё]…, поэтому НОМЕР ДОМА (число ПОСЛЕ названия, # не сопровождаемое буквой) в токен не попадает: «Белинского 86» → «белинского». # #2269 (б): тип-суффикс может быть приклеен запятой/точкой без пробела # («ул.,6к1»): после `\.?` разрешаем ЛИБО пробел (обычный формат), ЛИБО запятую # (→ следом номер дома). Ветка «тип ПЕРЕД именем» — только для формата с пробелом. _DEDUP_STREET_NAME_RE = re.compile( rf"(?:^|[,\s])(?:{_DEDUP_STREET_TYPE_ALT})\.?\s+" - r"((?:\d+(?:\s+|-))?[а-яё][а-яё\-]+(?:\s+[а-яё][а-яё\-]+){0,2})", + r"((?:\d+-[а-яё]\s+)?(?:\d+(?:\s+|-))?[а-яё][а-яё\-]+(?:\s+[а-яё][а-яё\-]+){0,2})", flags=re.IGNORECASE, ) # #2269 (а): «<название> <тип>» — тип-слово ПОСЛЕ имени (формат domklik: @@ -7350,10 +7352,19 @@ _DEDUP_STREET_NAME_RE = re.compile( # Рассветная». Тип НЕ входит в захват (group 1) — токен байт-в-байт совпадает. _DEDUP_STREET_NAME_SUFFIX_RE = re.compile( r"(?:^|[,\s])" - r"((?:\d+(?:\s+|-))?[а-яё][а-яё\-]+(?:\s+[а-яё][а-яё\-]+){0,2})\s+" + r"((?:\d+-[а-яё]\s+)?(?:\d+(?:\s+|-))?[а-яё][а-яё\-]+(?:\s+[а-яё][а-яё\-]+){0,2})\s+" rf"(?:{_DEDUP_STREET_TYPE_ALT})(?=\.|,|\s|$)", flags=re.IGNORECASE, ) +# #2291 класс 4: третий формат порядковой улицы — номер ПЕРЕД типом («8-я улица +# Текстильщиков», «2-я ул. Синичкина», «12-й пр. Марьиной Рощи»). Переставляем в +# «улица 8-я текстильщиков», чтобы он дал тот же токен, что «улица 8-я Текстильщиков» +# и «8-я Текстильщиков улица». Без перестановки ветка 1 взяла бы «текстильщиков» +# без номера, а номер нельзя терять: «9-я Парковая, 5» и «12-я Парковая, 5» — разные дома. +_DEDUP_ORDINAL_BEFORE_TYPE_RE = re.compile( + rf"(^|[,\s])(\d+-[а-яё]{{1,2}})\s+((?:{_DEDUP_STREET_TYPE_ALT})\.?)\s+", + flags=re.IGNORECASE, +) # #2269 (в): fallback без уличного типа — «микрорайон/мкр/мкр-н/м-н <имя>» или # «<имя> микрорайон». Имя = 1-3 буквенных слова. Токен получает префикс «mkr », # чтобы НЕ пересекаться с уличными токенами (микрорайон ≠ одноимённая улица). @@ -7539,6 +7550,7 @@ def _parse_street_house(addr: str | None) -> tuple[str, str]: # #2291 (класс 2): «ё» → «е» — «Шевелёва»/«Шевелева» и «Миномётчиков»/ # «Минометчиков» должны схлопываться в один канонический токен. s = s.replace("ё", "е") + s = _DEDUP_ORDINAL_BEFORE_TYPE_RE.sub(r"\1\3 \2 ", s) # Ветка 1 (существующая, #2265): тип ПЕРЕД именем («улица Рассветная»). # Пробуется ПЕРВОЙ — новые ветки только если она дала пусто. Логика не тронута. m = _DEDUP_STREET_NAME_RE.search(s) diff --git a/tradein-mvp/backend/tests/test_estimator_dedup_cross_source_2087.py b/tradein-mvp/backend/tests/test_estimator_dedup_cross_source_2087.py index 6370d80c..33eb220a 100644 --- a/tradein-mvp/backend/tests/test_estimator_dedup_cross_source_2087.py +++ b/tradein-mvp/backend/tests/test_estimator_dedup_cross_source_2087.py @@ -1040,3 +1040,65 @@ def test_dedup_hyphen_numbered_street_different_houses_stay_distinct() -> None: for h in ("8А", "35") ] assert len(estimator._dedup_cross_source(lots)) == 2 + + +# --------------------------------------------------------------------------- # +# #2291 класс 4 — однобуквенные порядковые («2-я Новосибирская», «3-й Конный») +# --------------------------------------------------------------------------- # + + +@pytest.mark.parametrize( + ("addr", "expected"), + [ + # Три формата одной улицы (живые адреса с прода, tradein-postgres, 17.09): + # cian «тип номер имя» раньше давал ('', ''), avito/yandex «номер тип имя» и + # domklik «номер имя тип» теряли номер ('синичкина'). + ("2-я ул. Синичкина,22", ("2-я синичкина", "22")), # avito + ("Москва, 2-я Синичкина улица, 22", ("2-я синичкина", "22")), # domklik + ("Россия, Москва, 2-я улица Синичкина, 22", ("2-я синичкина", "22")), # yandex + ("улица 2-я Новосибирская, 5", ("2-я новосибирская", "5")), + ("пер. 3-й Конный, 7", ("3-й конный", "7")), + ("ул. 1-й Пятилетки, 3", ("1-й пятилетки", "3")), + ("12-й пр. Марьиной Рощи,8с2", ("12-й марьиной рощи", "8")), + # Раньше мкр-ветка: ('mkr перловка', '1') — номер улицы вместо дома. + ("мкр. Перловка, улица 1-я Крестьянская, 33к2", ("1-я крестьянская", "33/2")), + # Порядковый перед нумерованным именем: без него было бы ('', ''). + ("2-й пер. 1 Мая,13", ("2-й 1 мая", "13")), + ( + "Россия, Московская область, Павловский Посад, 2-й переулок 1 Мая, 11", + ("2-й 1 мая", "11"), + ), + # Регрессии: номер дома с дефисом остаётся номером дома. + ("ул. Ленина, 12-а", ("ленина", "12")), + ("ул. Щорса, 7-б", ("щорса", "7")), + ], +) +def test_parse_street_house_single_letter_ordinal(addr: str, expected: tuple[str, str]) -> None: + assert estimator._parse_street_house(addr) == expected + + +def test_dedup_collapses_single_letter_ordinal_three_formats() -> None: + addrs = [ + ("avito", "2-я ул. Синичкина,22"), + ("domklik", "Москва, 2-я Синичкина улица, 22"), + ("yandex", "Россия, Москва, 2-я улица Синичкина, 22"), + ("cian", "ЮВАО, р-н Лефортово, улица 2-я Синичкина, 22"), + ] + lots = [ + _lot(source=s, address=a, area=44.0, price=9_900_000, floor=3, cad=None) for s, a in addrs + ] + assert len(estimator._dedup_cross_source(lots)) == 1 + + +def test_dedup_different_ordinal_streets_stay_distinct() -> None: + # Раньше domklik терял номер: «9-я Парковая» и «12-я Парковая» обе давали + # ('парковая', '5'), и квартиры с одинаковыми этажом/площадью/ценой в двух + # разных домах Измайлова сливались в одну. + lots = [ + _lot(source=s, address=a, area=44.0, price=9_900_000, floor=3, cad=None) + for s, a in ( + ("domklik", "Москва, 9-я Парковая улица, 5"), + ("domklik", "Москва, 12-я Парковая улица, 5"), + ) + ] + assert len(estimator._dedup_cross_source(lots)) == 2