diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index 8a855ca4..aacfbb49 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -4980,6 +4980,51 @@ _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-улицу. + Не найдено/не подходит → 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 + raw_words = segments[idx - 1].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 +5038,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 +5049,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 +5207,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..07315039 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,77 @@ 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("мкр. ЖБИ, улица Сыромолотова") == ("сыромолотова", "") + + +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