From 752c3656a347ab6caf9a1906cc3992d0e4b904e4 Mon Sep 17 00:00:00 2001 From: lekss361 Date: Sat, 4 Jul 2026 15:50:49 +0000 Subject: [PATCH] =?UTF-8?q?fix(tradein/estimator):=20=D0=B4=D0=BE=D0=BC=20?= =?UTF-8?q?=C2=AB2=C2=BB=20=D0=B2=D0=BC=D0=B5=D1=81=D1=82=D0=BE=20=C2=AB15?= =?UTF-8?q?=C2=BB=20=D0=BF=D1=80=D0=B8=20=D0=B3=D0=BB=D1=83=D1=85=D0=BE?= =?UTF-8?q?=D0=BC=20=D0=BD=D0=B0=D0=B7=D0=B2=D0=B0=D0=BD=D0=B8=D0=B8=20?= =?UTF-8?q?=D1=81=D1=82=D0=B0=D0=BD=D1=86=D0=B8=D0=B8=20=D0=BC=D0=B5=D1=82?= =?UTF-8?q?=D1=80=D0=BE=20=D0=B2=20avito-=D0=B0=D0=B4=D1=80=D0=B5=D1=81?= =?UTF-8?q?=D0=B5=20(#2429)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tradein-mvp/backend/app/services/estimator.py | 13 +- .../test_estimator_dedup_cross_source_2087.py | 149 +++++++++++++++++- 2 files changed, 159 insertions(+), 3 deletions(-) diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index 3a2839ca..f222c1fa 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -4954,8 +4954,19 @@ _DEDUP_MKR_SUFFIX_RE = re.compile( # Номер дома = первое число ПОСЛЕ названия улицы (опц. «д.»/«дом»), с литерой/ # корпусом («46», «8а», «12/3»). Ищется в остатке строки от конца street-матча — # ведущая цифра нумерованной улицы («8 Марта») сюда не попадает. +# #2423: суффикс-буква («46г», «8а») засчитывается только если ЗА НЕЙ не идёт +# ЕЩЁ ОДИН word-символ (`(?!\w)` — буква ИЛИ цифра), т.е. только если это +# последний word-символ токена. Иначе это не литера дома, а: (а) первая буква +# приклеенного без пробела слова (название станции метро в avito: +# «2Чкаловская11-15 мин» → дом «2», а не «15» из хвоста дистанции), либо +# (б) буква корпуса, приклеенная без пробела к своей цифре («6к1» → дом «6», +# ту же цифру даёт и вариант с пробелом «6 к1» — иначе кросс-source guard +# ошибочно видел бы «6к» vs «6» как разные дома и блокировал слияние). +# Trailing `\b` намеренно убран: он ложно "отменял" весь матч цифры, когда +# сразу за ней шло кириллическое слово без разделителя (Cyrillic letter и +# digit — оба `\w`, между ними нет word-boundary). _DEDUP_HOUSE_NO_RE = re.compile( - r"\b(?:д(?:ом)?\.?\s*)?(\d+(?:[а-яё]|/\d+)?)\b", + r"\b(?:д(?:ом)?\.?\s*)?(\d+(?:[а-яё](?!\w)|/\d+)?)", flags=re.IGNORECASE, ) # Слова-маркеры дома, которые могли попасть в захват названия (напр. «ленина д 5»). 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 ba744d3f..c5be831b 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 @@ -411,7 +411,12 @@ def test_dedup_empty_cadnum_and_street_stay_separate(monkeypatch: pytest.MonkeyP [ # --- контрольные (существующая ветка «тип ПЕРЕД именем») — БАЙТ-В-БАЙТ # тот же токен, что дают новые ветки, иначе клейка развалится. - ("улица Рассветная, 6к1", ("рассветная", "")), # cian-стиль, house не извлекается + # #2423: «6к1» (cian-стиль, литера корпуса приклеена без пробела к + # цифре) теперь даёт house="6", а не "" — та же цифра, что и у + # пробельного «6 к1» ниже (домклик-стиль), т.е. извлечение стало + # согласованным между форматами (было: полный отказ матча из-за + # word-boundary collapse между цифрой и кириллической буквой). + ("улица Рассветная, 6к1", ("рассветная", "6")), ("ул. Белинского 86", ("белинского", "86")), ("улица 8 Марта, 46", ("8 марта", "46")), # нумерованная, ведущая цифра в токене # --- (а) имя ПЕРЕД типом (формат domklik) @@ -420,7 +425,8 @@ def test_dedup_empty_cadnum_and_street_stay_separate(monkeypatch: pytest.MonkeyP ("Красных Командиров улица, 10", ("красных командиров", "10")), ("8 Марта улица, 46", ("8 марта", "46")), # нумерованная, имя-перед-типом # --- (б) тип-суффикс приклеен запятой/точкой без пробела (avito) - ("Рассветная ул.,6к1", ("рассветная", "")), + # #2423: аналогично, house="6" вместо "" (см. комментарий выше). + ("Рассветная ул.,6к1", ("рассветная", "6")), ("Белинского ул., 86", ("белинского", "86")), # --- (в) fallback без уличного типа — микрорайон, префикс «mkr » ("Екатеринбург, Светлый микрорайон, 10", ("mkr светлый", "10")), @@ -637,3 +643,142 @@ def test_dedup_collapses_yo_ye_variant(monkeypatch: pytest.MonkeyPatch) -> None: ] out = estimator._dedup_cross_source(lots) assert len(out) == 1 + + +# --------------------------------------------------------------------------- # +# #2423 avito glued-metro-name house-number extraction +# --------------------------------------------------------------------------- # + + +def test_parse_street_house_avito_glued_metro_suffix() -> None: + # Root cause прод-кейса: станция метро приклеена к номеру дома без + # разделителя («2Чкаловская»). Trailing `\b` в старом regex ложно отменял + # матч цифры целиком (Cyrillic-буква и цифра — оба `\w`, границы нет), и + # .search() уезжал вправо до «11-15» (дистанция до метро), где дефис даёт + # настоящую границу → дом ошибочно распознавался как «15» вместо «2». + addr = "ул. Амундсена, 2Чкаловская11-15 мин. пешком" + assert estimator._parse_street_house(addr) == ("амундсена", "2") + + +def test_parse_street_house_avito_glued_metro_hyphenated_name() -> None: + # Приклеенное название станции само содержит дефис — фикс не должен + # зависеть от того, есть ли внутри глухого слова дефис или нет. + addr = "ул. Космонавтов, 14Ботаническая-Уктус7-12 мин. пешком" + assert estimator._parse_street_house(addr) == ("космонавтов", "14") + + +def test_parse_street_house_avito_glued_metro_short_name() -> None: + # Адверсариальный минимум: приклеенное слово всего 2 буквы — lookahead + # должен отклонить суффикс-букву даже на короткой глухой строке, не + # только на длинных названиях станций. + addr = "ул. Мира, 9ВЗ5-8 мин. пешком" + assert estimator._parse_street_house(addr) == ("мира", "9") + + +@pytest.mark.parametrize( + ("house_part", "expected_house"), + [ + ("24г", "24г"), + ("32а", "32а"), + ("5б", "5б"), + ], +) +def test_parse_street_house_letter_suffix_not_regressed( + house_part: str, expected_house: str +) -> None: + # Настоящая литера дома (не продолжается ещё одной кириллической буквой, + # т.е. это последний word-символ токена) — поведение не должно измениться. + addr = f"ул. Ленина, {house_part}" + assert estimator._parse_street_house(addr) == ("ленина", expected_house) + + +def test_parse_street_house_corpus_slash_not_regressed() -> None: + # «12/3» (корпус через слэш) — вторая альтернатива (`/\d+`) не тронута фиксом. + assert estimator._parse_street_house("ул. Ленина, 12/3") == ("ленина", "12/3") + + +def test_parse_street_house_glued_corpus_letter_partial_capture() -> None: + # #2423: «10к1» без пробела перед литерой корпуса — литера «к» отклоняется + # как суффикс, т.к. за ней идёт ЕЩЁ ОДИН word-символ (цифра «1» корпуса), + # `(?!\w)` (не только `(?![а-яё])` из первоначального черновика фикса) + # отсекает и этот случай. Даёт «10» — тот же номер дома, что и у + # пробельного варианта «10 к1» (см. регрессию ниже) → house-guard видит + # одинаковый номер дома в обоих написаниях, кросс-source merge не ломается. + # ВАЖНО: черновой вариант фикса с `(?![а-яё])` (отклонять суффикс только + # если за ним ЕЩЁ ОДНА БУКВА) давал «10к» здесь — рассинхронизация с + # пробельным вариантом «6 к1» → «6» сломала 2 существующих e2e-теста + # (test_dedup_collapses_domklik_cian_name_order, + # test_dedup_collapses_domklik_cian_area_straddle), т.к. guard_house видел + # «6к» != «6» и блокировал слияние. Обнаружено прогоном полного набора + # тестов при реализации — `(?!\w)` устраняет регрессию. + assert estimator._parse_street_house("ул. Ленина, 10к1") == ("ленина", "10") + + +def test_parse_street_house_ambiguous_glued_digit_runs_limitation() -> None: + # #2423 limitation: два приклеенных друг к другу чисто-числовых прогона + # без буквы/пунктуации между ними принципиально неразличимы регэкспом — + # какое число дом, а какое дистанция, знает только семантика. В реальных + # avito-адресах между домом и дистанцией всегда есть «мин»/имя станции + # (см. тесты выше), так что этот кейс не встречается в проде; закрепляем + # текущий (не «исправленный») результат, чтобы будущее изменение было + # осознанным видимым диффом, а не тихой регрессией. + assert estimator._parse_street_house("ул. Ленина, 7500 м до метро") == ("ленина", "7500") + + +def test_dedup_collapses_avito_glued_metro_suffix_house_match( + monkeypatch: pytest.MonkeyPatch, +) -> None: + # e2e: avito-лот с приклеенным названием станции (дом «2» до фикса терялся, + # извлекалось «15») + чистый cian-адрес того же дома «2» → house-guard + # больше не блокирует street-union (оба извлекают «2») → один физлот. + monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True) + lots = [ + _lot( + source="avito", + address="ул. Амундсена, 2Чкаловская11-15 мин. пешком", + area=45.0, + price=6_100_000, + floor=5, + cad=None, + ), + _lot( + source="cian", + address="ул. Амундсена, 2", + area=45.0, + price=6_100_000, + floor=5, + cad=None, + ), + ] + out = estimator._dedup_cross_source(lots) + assert len(out) == 1 + + +def test_dedup_glued_metro_noise_does_not_cause_over_merge( + monkeypatch: pytest.MonkeyPatch, +) -> None: + # Анти-регрессия: фикс не должен сделать guard_house снисходительнее для + # адресов с глухим метро-шумом в общем случае — ДЕЙСТВИТЕЛЬНО разные дома + # («24» vs «26»), каждый одет в приклеенное название станции разной длины, + # ДОЛЖНЫ остаться раздельными (guard всё ещё блокирует both-present-different). + monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True) + lots = [ + _lot( + source="avito", + address="ул. Гагарина, 24Чкаловская5-8 мин. пешком", + area=45.0, + price=6_100_000, + floor=5, + cad=None, + ), + _lot( + source="cian", + address="ул. Гагарина, 26Ботаническая11-15 мин. пешком", + area=45.0, + price=6_100_000, + floor=5, + cad=None, + ), + ] + out = estimator._dedup_cross_source(lots) + assert len(out) == 2