From a0c609caf42455fbd25cac5edda5d0d9497fa0fb Mon Sep 17 00:00:00 2001 From: lekss361 Date: Sat, 4 Jul 2026 12:58:15 +0000 Subject: [PATCH] =?UTF-8?q?fix(tradein/estimator):=20=D1=83=D1=81=D1=82?= =?UTF-8?q?=D1=80=D0=B0=D0=BD=D0=B8=D1=82=D1=8C=20=D0=BE=D1=81=D1=82=D0=B0?= =?UTF-8?q?=D1=82=D0=BE=D1=87=D0=BD=D1=8B=D0=B5=20=D0=BA=D1=80=D0=BE=D1=81?= =?UTF-8?q?=D1=81-=D1=81=D0=BE=D1=80=D1=81=20=D0=B4=D1=83=D0=B1=D0=BB?= =?UTF-8?q?=D0=B8=20(#2291)=20(#2417)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tradein-mvp/backend/app/services/estimator.py | 88 ++++++++++++ .../test_estimator_dedup_cross_source_2087.py | 125 ++++++++++++++++++ 2 files changed, 213 insertions(+) diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index 8a855ca4..3a2839ca 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -4980,6 +4980,71 @@ _DEDUP_ADDR_NOISE_WORDS = frozenset( "квартал", } ) +# #2291 (класс 1): токен-множества для bare-street-ветки (см. ниже) — прямая +# проверка отдельных слов сегмента, без построения регэкспа. +_DEDUP_MKR_WORDS = frozenset({"микрорайон", "мкр-н", "мкр", "м-н"}) +_DEDUP_STREET_TYPE_WORDS_SET = frozenset(_DEDUP_STREET_TYPE_WORDS) + + +def _bare_street_before_house_segment(s: str) -> tuple[str, str] | None: + """Bare-street ветка (#2291, класс 1): «<мкр-сегмент>, <имя>, <номер>». + + Root cause: `_DEDUP_MKR_SUFFIX_RE` матчит «<имя> м-н/мкр» ГДЕ УГОДНО в + строке. Для DomClick-формата «Заречный м-н, Готвальда, 24» — голая улица + БЕЗ типа-слова после района — ветки 1/2 (требуют тип-слово) молчат, и + mkr-fallback (ветка 3) неверно берёт ключ по РАЙОНУ («mkr заречный») + вместо реальной улицы «готвальда» → лот никогда не сливается с avito/cian + того же дома, где «готвальда» распознаётся корректно. + + Разбивает адрес по запятым; ищет ПЕРВЫЙ сегмент, ЦЕЛИКОМ совпадающий с + `_DEDUP_HOUSE_NO_RE` (номер дома). Сегмент НЕПОСРЕДСТВЕННО перед ним — 1-3 + слова, без мкр-слова/уличного типа/шум-токена — кандидат в bare-улицу. + + #2291 over-merge guard (deep-review, blocking): ветка активируется ТОЛЬКО + для 3+ сегментного district-chain паттерна — обязателен ЕЩЁ более ранний + сегмент (до кандидата-улицы), который сам содержит мкр/район-слово + (`_DEDUP_MKR_WORDS`). Без этого условия голые 2-сегментные адреса без + какого-либо mkr-контекста («ВИЗ, 24», «Академический, 24», «ЖК Некий, + 24» — частый неформальный ЕКБ-шорткат в объявлениях) фабриковали бы + уличный токен из названия района/ЖК и рисковали ложным cross-merge двух + РАЗНЫХ физлотов. Такие адреса должны вернуть None (решают ветки 1/2/3; + итог — безопасный `("", "")`, как и до всей этой ветки). + + Не найдено/не подходит → None (решают ветки 1/2/3, в частности mkr-fallback + для «мкр Пионерский, 5», где сегмент перед номером содержит мкр-слово). + """ + segments = s.split(",") + for idx in range(1, len(segments)): + house_seg = segments[idx].strip() + if not house_seg: + continue + hm = _DEDUP_HOUSE_NO_RE.fullmatch(house_seg) + if hm is None: + continue + street_idx = idx - 1 + earlier_segments = segments[:street_idx] + if not earlier_segments or not any( + any(w in _DEDUP_MKR_WORDS for w in seg.strip().split()) for seg in earlier_segments + ): + # Нет более раннего сегмента с mkr/район-словом → это не заявленный + # 3-сегментный district-chain, а голый 2-сегментный адрес — не + # фабрикуем уличный токен, отдаём другим веткам (итог — "", ""). + continue + raw_words = segments[street_idx].strip().split() + if not raw_words or len(raw_words) > 3: + continue + if any( + w in _DEDUP_MKR_WORDS + or w in _DEDUP_ADDR_NOISE_WORDS + or w in _DEDUP_STREET_TYPE_WORDS_SET + for w in raw_words + ): + continue # район/мкр/голое тип-слово — не улица, отдаём другим веткам + words = [w for w in raw_words if w not in _DEDUP_HOUSE_MARKER_WORDS] + if not words: + continue + return " ".join(words), hm.group(1) + return None def _parse_street_house(addr: str | None) -> tuple[str, str]: @@ -4993,6 +5058,9 @@ def _parse_street_house(addr: str | None) -> tuple[str, str]: if not addr: return "", "" s = addr.strip().lower() + # #2291 (класс 2): «ё» → «е» — «Шевелёва»/«Шевелева» и «Миномётчиков»/ + # «Минометчиков» должны схлопываться в один канонический токен. + s = s.replace("ё", "е") # Ветка 1 (существующая, #2265): тип ПЕРЕД именем («улица Рассветная»). # Пробуется ПЕРВОЙ — новые ветки только если она дала пусто. Логика не тронута. m = _DEDUP_STREET_NAME_RE.search(s) @@ -5001,6 +5069,14 @@ def _parse_street_house(addr: str | None) -> tuple[str, str]: # Ветка 2 (#2269 а/б): имя ПЕРЕД типом, тип-суффикс с пробелом или # приклеенный запятой/точкой («Рассветная улица», «Рассветная ул.,6к1»). m = _DEDUP_STREET_NAME_SUFFIX_RE.search(s) + if m is None: + # Ветка bare-street (#2291, класс 1): «<имя>, <номер>» без типа/мкр- + # слова прямо перед номером дома — СТРОГО перед mkr-fallback (веткой + # 3), иначе «мкр Пионерский, 5» тоже сюда попала бы (см. docstring + # _bare_street_before_house_segment). + bare = _bare_street_before_house_segment(s) + if bare is not None: + return bare if m is None: # Ветка 3 (#2269 в): без уличного типа — микрорайон-fallback. Токен # получает префикс «mkr », чтобы не пересекаться с уличными токенами. @@ -5151,11 +5227,23 @@ def _dedup_cross_source(lots: list[dict[str, Any]]) -> list[dict[str, Any]]: union(prev, i, guard_house=False) # 2) street-композит — union при отсутствии cadnum- И house-конфликта. + # #2291 (класс 3): area_b — банкирское округление (round()), физически + # одинаковый лот, измеренный source'ами чуть по-разному (38.5 vs 39.0), + # может попасть в СОСЕДНИЕ bucket'ы и никогда не сравниться. Допуск ±1 + # bucket СКОПИРОВАН только на street-union lookup (не на cadnum-union, не + # на сам area_bucket — расширение бакета вызвало бы over-merge). Owner + # регистрируется ВСЕГДА под своим ТОЧНЫМ ключом (не под ключом соседа) — + # иначе допуск мог бы дрейфовать сколь угодно далеко цепочкой ±1,±1,±1… street_owner: dict[tuple[str, Any, int, int], int] = {} for i, (_cad_s, _house, _cad_key, street_key) in enumerate(comps): if street_key is None: continue + street, floor, area_b, price_b = street_key prev = street_owner.get(street_key) + if prev is None: + prev = street_owner.get((street, floor, area_b - 1, price_b)) + if prev is None: + prev = street_owner.get((street, floor, area_b + 1, price_b)) if prev is None: street_owner[street_key] = i else: 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 ac045cf7..ba744d3f 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 @@ -488,6 +488,37 @@ def test_dedup_collapses_domklik_cian_name_order(monkeypatch: pytest.MonkeyPatch assert out[0]["source"] == "cian" # представитель — свежайший scraped_at +def test_dedup_collapses_domklik_cian_area_straddle(monkeypatch: pytest.MonkeyPatch) -> None: + # #2291 класс 3: тот же физлот «Рассветная 6к1», но area округляется в + # СОСЕДНИЕ area_bucket банкирским round() — 38.5 → 38 (округление к чётному), + # 39.0 → 39. До фикса разные area_bucket → street-owner lookup никогда не + # находил друг друга → лот считался дважды. ±1-bucket допуск на street-union + # должен склеить их в один физлот. + 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=39.0, + 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 # склеились несмотря на area straddle 38.5/39.0 + + def test_dedup_collapses_avito_glued_suffix(monkeypatch: pytest.MonkeyPatch) -> None: # (б) avito с глухим суффиксом «Рассветная ул.,6к1» + cian «улица Рассветная, # 6к1» → тот же токен «рассветная», house не извлекается ни у одного (guard @@ -512,3 +543,97 @@ def test_dedup_mkr_fallback_does_not_cross_with_street(monkeypatch: pytest.Monke ] out = estimator._dedup_cross_source(lots) assert len(out) == 2 + + +# --------------------------------------------------------------------------- # +# #2291 — bare-street ветка (класс 1) + ё/е нормализация (класс 2) +# --------------------------------------------------------------------------- # + + +def test_parse_street_house_bare_street_before_house_no() -> None: + # Класс 1 (#2291): DomClick-формат «<район без типа>, <улица без типа>, + # <номер>» — «Заречный м-н» (район) БЕЗ уличного типа перед реальной + # улицей «Готвальда». Ветки 1/2 требуют тип-слово и молчат, mkr-fallback + # раньше неверно ключевал по району («mkr заречный»). Bare-street ветка + # берёт сегмент ПЕРЕД номером дома — реальную улицу «готвальда». + assert estimator._parse_street_house("Заречный м-н, Готвальда, 24") == ("готвальда", "24") + + +def test_parse_street_house_bare_street_regression_guards() -> None: + # Регрессия: «мкр Пионерский, 5» — сегмент перед номером («мкр пионерский») + # содержит мкр-слово → bare-street ветка ДОЛЖНА пропустить, mkr-fallback + # резолвит как раньше (mkr-префикс). + assert estimator._parse_street_house("мкр Пионерский, 5") == ("mkr пионерский", "5") + # Регрессия #2265: явный уличный тип «улица» присутствует → резолвится + # ВЕТКОЙ 1, bare-street ветка (после веток 1/2) даже не достигается. + assert estimator._parse_street_house("мкр. ЖБИ, улица Сыромолотова") == ("сыромолотова", "") + + +@pytest.mark.parametrize( + "addr", + [ + # Deep-review over-merge risk (#2291): голый 2-сегментный адрес БЕЗ + # какого-либо mkr/район-контекста — частый неформальный ЕКБ-шорткат в + # объявлениях (район/ЖК + номер дома, без слова «улица»/«мкр»/«м-н»). + # Bare-street ветка НЕ должна фабриковать уличный токен из района/ЖК — + # нет более раннего сегмента с mkr-словом → должно остаться ("", ""). + "ВИЗ, 24", + "Академический, 24", + "ЖК Некий, 24", + ], +) +def test_parse_street_house_bare_street_requires_earlier_mkr_segment(addr: str) -> None: + # Тот же bare-2-сегментный паттерн, что у целевого кейса («<имя>, <номер>»), + # но БЕЗ более раннего mkr-сегмента — ветка должна самоограничиться и + # вернуть безопасный ("", ""), а не сфабрикованный уличный токен. + assert estimator._parse_street_house(addr) == ("", "") + + +def test_dedup_collapses_domclick_bare_street_mkr_misfire(monkeypatch: pytest.MonkeyPatch) -> None: + # Класс 1 (#2291) e2e: DomClick «Заречный м-н, Готвальда, 24» раньше + # ключевался по району и никогда не сливался с avito/cian, где «готвальда» + # распознаётся явной веткой (тип-слово «улица»). + monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True) + lots = [ + _lot( + source="domclick", + address="Заречный м-н, Готвальда, 24", + area=45.0, + price=6_000_000, + floor=2, + cad=None, + ), + _lot( + source="avito", + address="улица Готвальда, 24", + area=45.0, + price=6_000_000, + floor=2, + cad=None, + ), + ] + out = estimator._dedup_cross_source(lots) + assert len(out) == 1 + + +def test_parse_street_house_yo_ye_fold() -> None: + # Класс 2 (#2291): «ё» → «е» — «Шевелёва»/«Шевелева» и «Миномётчиков»/ + # «Минометчиков» должны давать один и тот же канонический токен. + assert estimator._parse_street_house("улица Шевелёва, 12") == estimator._parse_street_house( + "улица Шевелева, 12" + ) + assert estimator._parse_street_house("улица Миномётчиков, 5") == estimator._parse_street_house( + "улица Минометчиков, 5" + ) + + +def test_dedup_collapses_yo_ye_variant(monkeypatch: pytest.MonkeyPatch) -> None: + # Класс 2 (#2291) e2e: один и тот же физлот в двух source-форматах — + # «Шевелёва» (avito) и «Шевелева» (cian) — теперь схлопывается в один. + monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True) + lots = [ + _lot(source="avito", address="улица Шевелёва, 12", area=42.0, price=5_200_000, floor=4), + _lot(source="cian", address="улица Шевелева, 12", area=42.0, price=5_200_000, floor=4), + ] + out = estimator._dedup_cross_source(lots) + assert len(out) == 1