fix(tradein/estimator): устранить остаточные кросс-сорс дубли — mkr-misfire/ё-е/area-straddle (#2291)

bare-street branch before mkr-fallback (334), е/е fold (~60), +-1 area-bucket
tolerance on street-union (139) - class 4 (empty tokens, ~50) explicitly deferred, low ROI.
This commit is contained in:
bot-backend 2026-07-04 15:24:53 +03:00
parent eafa305ac8
commit 859d4fa317
2 changed files with 173 additions and 0 deletions

View file

@ -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:

View file

@ -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