From 666962185c48c45a09e192c94063b0fa334650ab Mon Sep 17 00:00:00 2001 From: bot-backend Date: Fri, 3 Jul 2026 13:03:25 +0000 Subject: [PATCH] =?UTF-8?q?fix(tradein/estimator):=20=D0=BF=D0=B0=D1=80?= =?UTF-8?q?=D1=81=D0=B5=D1=80=20=D0=B0=D0=B4=D1=80=D0=B5=D1=81=D0=BE=D0=B2?= =?UTF-8?q?=20=E2=80=94=20=D0=B8=D0=BC=D1=8F-=D0=BF=D0=B5=D1=80=D0=B5?= =?UTF-8?q?=D0=B4-=D1=82=D0=B8=D0=BF=D0=BE=D0=BC,=20glued-=D1=81=D1=83?= =?UTF-8?q?=D1=84=D1=84=D0=B8=D0=BA=D1=81,=20=D0=BC=D0=B8=D0=BA=D1=80?= =?UTF-8?q?=D0=BE=D1=80=D0=B0=D0=B9=D0=BE=D0=BD=D1=8B=20(#2269)=20(#2290)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tradein-mvp/backend/app/services/estimator.py | 50 +++++++- .../test_estimator_dedup_cross_source_2087.py | 113 ++++++++++++++++++ 2 files changed, 162 insertions(+), 1 deletion(-) diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index cf506ff1..6af1c127 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -4623,11 +4623,43 @@ _DEDUP_STREET_TYPE_ALT = "|".join( # («8 Марта», «40 лет Октября», «22 Партсъезда»); она разрешена ТОЛЬКО перед # буквенным словом (?:\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})", flags=re.IGNORECASE, ) +# #2269 (а): «<название> <тип>» — тип-слово ПОСЛЕ имени (формат domklik: +# «Рассветная улица, 6 к1»; avito с глухим суффиксом: «Рассветная ул.,6к1»). +# Название — 1-3 буквенных слова (+ опц. ведущая цифра нумерованной улицы) на +# границе запятой/начала строки, СРАЗУ перед типом. Тип завершается концом +# слова / точкой / запятой / пробелом — так «Рассветная ул.,6к1» и «Рассветная +# улица, 6» дают тот же токен «рассветная», что ветка-перед-именем на «улица +# Рассветная». Тип НЕ входит в захват (group 1) — токен байт-в-байт совпадает. +_DEDUP_STREET_NAME_SUFFIX_RE = re.compile( + r"(?:^|[,\s])" + r"((?:\d+\s+)?[а-яё][а-яё\-]+(?:\s+[а-яё][а-яё\-]+){0,2})\s+" + rf"(?:{_DEDUP_STREET_TYPE_ALT})(?=\.|,|\s|$)", + flags=re.IGNORECASE, +) +# #2269 (в): fallback без уличного типа — «микрорайон/мкр/мкр-н/м-н <имя>» или +# «<имя> микрорайон». Имя = 1-3 буквенных слова. Токен получает префикс «mkr », +# чтобы НЕ пересекаться с уличными токенами (микрорайон ≠ одноимённая улица). +_DEDUP_MKR_WORDS_ALT = "|".join( + re.escape(w) for w in sorted(("микрорайон", "мкр-н", "мкр", "м-н"), key=len, reverse=True) +) +_DEDUP_MKR_PREFIX_RE = re.compile( + rf"(?:^|[,\s])(?:{_DEDUP_MKR_WORDS_ALT})\.?\s+" r"([а-яё][а-яё\-]+(?:\s+[а-яё][а-яё\-]+){0,2})", + flags=re.IGNORECASE, +) +_DEDUP_MKR_SUFFIX_RE = re.compile( + r"(?:^|[,\s])" + r"([а-яё][а-яё\-]+(?:\s+[а-яё][а-яё\-]+){0,2})\s+" + rf"(?:{_DEDUP_MKR_WORDS_ALT})(?=\.|,|\s|$)", + flags=re.IGNORECASE, +) # Номер дома = первое число ПОСЛЕ названия улицы (опц. «д.»/«дом»), с литерой/ # корпусом («46», «8а», «12/3»). Ищется в остатке строки от конца street-матча — # ведущая цифра нумерованной улицы («8 Марта») сюда не попадает. @@ -4670,8 +4702,20 @@ def _parse_street_house(addr: str | None) -> tuple[str, str]: if not addr: return "", "" s = addr.strip().lower() + # Ветка 1 (существующая, #2265): тип ПЕРЕД именем («улица Рассветная»). + # Пробуется ПЕРВОЙ — новые ветки только если она дала пусто. Логика не тронута. m = _DEDUP_STREET_NAME_RE.search(s) - if not m: + mkr = False + if m is None: + # Ветка 2 (#2269 а/б): имя ПЕРЕД типом, тип-суффикс с пробелом или + # приклеенный запятой/точкой («Рассветная улица», «Рассветная ул.,6к1»). + m = _DEDUP_STREET_NAME_SUFFIX_RE.search(s) + if m is None: + # Ветка 3 (#2269 в): без уличного типа — микрорайон-fallback. Токен + # получает префикс «mkr », чтобы не пересекаться с уличными токенами. + m = _DEDUP_MKR_PREFIX_RE.search(s) or _DEDUP_MKR_SUFFIX_RE.search(s) + mkr = m is not None + if m is None: return "", "" words = [ w @@ -4679,6 +4723,10 @@ def _parse_street_house(addr: str | None) -> tuple[str, str]: if w and w not in _DEDUP_HOUSE_MARKER_WORDS and w not in _DEDUP_ADDR_NOISE_WORDS ] street = " ".join(words).strip() + if not street: + return "", "" + if mkr: + street = f"mkr {street}" house = "" hm = _DEDUP_HOUSE_NO_RE.search(s, m.end()) if hm: 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 ee905255..ac045cf7 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 @@ -399,3 +399,116 @@ def test_dedup_empty_cadnum_and_street_stay_separate(monkeypatch: pytest.MonkeyP ] out = estimator._dedup_cross_source(lots) assert len(out) == 2 + + +# --------------------------------------------------------------------------- # +# #2269 — расширенный парсер адреса (три source-формата, ронявшие ('','')) +# --------------------------------------------------------------------------- # + + +@pytest.mark.parametrize( + ("addr", "expected"), + [ + # --- контрольные (существующая ветка «тип ПЕРЕД именем») — БАЙТ-В-БАЙТ + # тот же токен, что дают новые ветки, иначе клейка развалится. + ("улица Рассветная, 6к1", ("рассветная", "")), # cian-стиль, house не извлекается + ("ул. Белинского 86", ("белинского", "86")), + ("улица 8 Марта, 46", ("8 марта", "46")), # нумерованная, ведущая цифра в токене + # --- (а) имя ПЕРЕД типом (формат domklik) + ("Рассветная улица, 6 к1", ("рассветная", "6")), + ("Россия, Екатеринбург, Рассветная улица, 6", ("рассветная", "6")), + ("Красных Командиров улица, 10", ("красных командиров", "10")), + ("8 Марта улица, 46", ("8 марта", "46")), # нумерованная, имя-перед-типом + # --- (б) тип-суффикс приклеен запятой/точкой без пробела (avito) + ("Рассветная ул.,6к1", ("рассветная", "")), + ("Белинского ул., 86", ("белинского", "86")), + # --- (в) fallback без уличного типа — микрорайон, префикс «mkr » + ("Екатеринбург, Светлый микрорайон, 10", ("mkr светлый", "10")), + ("микрорайон Светлый, 10", ("mkr светлый", "10")), + ("мкр Пионерский, 5", ("mkr пионерский", "5")), + ("м-н ЖБИ, 3", ("mkr жби", "3")), + # --- всё ещё пусто, если ни улицы, ни микрорайона + ("Екатеринбург, ЖК Дружба", ("", "")), + ("улица, 5", ("", "")), # тип без имени + ], +) +def test_parse_street_house_extended_formats(addr: str, expected: tuple[str, str]) -> None: + assert estimator._parse_street_house(addr) == expected + + +def test_parse_street_house_branch1_wins_over_mkr_noise() -> None: + # #2265 регрессия: когда в адресе ЕСТЬ уличный тип, ветка-перед-именем берёт + # улицу, а «мкр/м-н» остаётся шумом (НЕ префикс mkr) — токен = «сыромолотова». + assert estimator._parse_street_house("р-н Кировский, мкр. ЖБИ, улица Сыромолотова, 11") == ( + "сыромолотова", + "11", + ) + assert estimator._parse_street_house("Россия, Екатеринбург, ЖБИ м-н, улица Сыромолотова") == ( + "сыромолотова", + "", + ) + + +def test_extract_street_token_extended_same_token_across_formats() -> None: + # Все три source-формата дома «Рассветная» → один и тот же токен → склейка. + domklik = estimator._extract_street_token("Рассветная улица, 6 к1") + avito = estimator._extract_street_token("Рассветная ул.,6к1") + cian = estimator._extract_street_token("улица Рассветная, 6к1") + assert domklik == avito == cian == "рассветная" + + +def test_dedup_collapses_domklik_cian_name_order(monkeypatch: pytest.MonkeyPatch) -> None: + # Живой пример несклейки (оценка 23b8ada1): domklik «Рассветная улица, 6 к1» + # + cian «улица Рассветная, 6к1» — тот же физлот (эт.13, ~38.5 м², 6.599М), + # cadnum пуст (domklik всегда NULL). Расширенный парсер даёт обоим токен + # «рассветная» → union-find по street-композиту схлопывает в один. + monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True) + lots = [ + _lot( + source="domklik", + address="Рассветная улица, 6 к1", + area=38.5, + price=6_599_000, + floor=13, + cad=None, + scraped_at=datetime(2026, 7, 1, tzinfo=UTC), + ), + _lot( + source="cian", + address="улица Рассветная, 6к1", + area=38.5, + price=6_599_000, + floor=13, + cad=None, + scraped_at=datetime(2026, 7, 3, tzinfo=UTC), + ), + ] + out = estimator._dedup_cross_source(lots) + assert len(out) == 1 # две записи → одна + assert out[0]["source"] == "cian" # представитель — свежайший scraped_at + + +def test_dedup_collapses_avito_glued_suffix(monkeypatch: pytest.MonkeyPatch) -> None: + # (б) avito с глухим суффиксом «Рассветная ул.,6к1» + cian «улица Рассветная, + # 6к1» → тот же токен «рассветная», house не извлекается ни у одного (guard + # equal-or-missing) → склейка. + monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True) + lots = [ + _lot(source="avito", address="Рассветная ул.,6к1", area=38.5, price=6_599_000, floor=13), + _lot(source="cian", address="улица Рассветная, 6к1", area=38.5, price=6_599_000, floor=13), + ] + out = estimator._dedup_cross_source(lots) + assert len(out) == 1 + + +def test_dedup_mkr_fallback_does_not_cross_with_street(monkeypatch: pytest.MonkeyPatch) -> None: + # Префикс «mkr » изолирует микрорайонный токен от одноимённого уличного: + # «Светлый микрорайон» (mkr) и «улица Светлая» (street) — РАЗНЫЕ ключи, не + # сливаются даже при совпадении метрик. + monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True) + lots = [ + _lot(source="cian", address="Светлый микрорайон, 10", area=40.0, price=5_000_000, floor=6), + _lot(source="avito", address="улица Светлая, 10", area=40.0, price=5_000_000, floor=6), + ] + out = estimator._dedup_cross_source(lots) + assert len(out) == 2