diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index aacfbb49..3a2839ca 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -4987,7 +4987,7 @@ _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): «<имя>, <номер>» без типа/мкр-слова. + """Bare-street ветка (#2291, класс 1): «<мкр-сегмент>, <имя>, <номер>». Root cause: `_DEDUP_MKR_SUFFIX_RE` матчит «<имя> м-н/мкр» ГДЕ УГОДНО в строке. Для DomClick-формата «Заречный м-н, Готвальда, 24» — голая улица @@ -4999,6 +4999,17 @@ def _bare_street_before_house_segment(s: str) -> tuple[str, str] | None: Разбивает адрес по запятым; ищет ПЕРВЫЙ сегмент, ЦЕЛИКОМ совпадающий с `_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», где сегмент перед номером содержит мкр-слово). """ @@ -5010,7 +5021,16 @@ def _bare_street_before_house_segment(s: str) -> tuple[str, str] | None: hm = _DEDUP_HOUSE_NO_RE.fullmatch(house_seg) if hm is None: continue - raw_words = segments[idx - 1].strip().split() + 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( 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 07315039..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 @@ -569,6 +569,26 @@ def test_parse_street_house_bare_street_regression_guards() -> None: 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, где «готвальда»