fix(tradein/estimator): устранить остаточные кросс-сорс дубли (#2291) (#2417)
All checks were successful
Deploy Trade-In / changes (push) Successful in 10s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 57s
Deploy Trade-In / build-backend (push) Successful in 58s
Deploy Trade-In / deploy (push) Successful in 48s
All checks were successful
Deploy Trade-In / changes (push) Successful in 10s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 57s
Deploy Trade-In / build-backend (push) Successful in 58s
Deploy Trade-In / deploy (push) Successful in 48s
This commit is contained in:
parent
8030f8ecef
commit
a0c609caf4
2 changed files with 213 additions and 0 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue