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]:
|
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:
|
if not addr:
|
||||||
return "", ""
|
return "", ""
|
||||||
s = addr.strip().lower()
|
s = addr.strip().lower()
|
||||||
|
# #2291 (класс 2): «ё» → «е» — «Шевелёва»/«Шевелева» и «Миномётчиков»/
|
||||||
|
# «Минометчиков» должны схлопываться в один канонический токен.
|
||||||
|
s = s.replace("ё", "е")
|
||||||
# Ветка 1 (существующая, #2265): тип ПЕРЕД именем («улица Рассветная»).
|
# Ветка 1 (существующая, #2265): тип ПЕРЕД именем («улица Рассветная»).
|
||||||
# Пробуется ПЕРВОЙ — новые ветки только если она дала пусто. Логика не тронута.
|
# Пробуется ПЕРВОЙ — новые ветки только если она дала пусто. Логика не тронута.
|
||||||
m = _DEDUP_STREET_NAME_RE.search(s)
|
m = _DEDUP_STREET_NAME_RE.search(s)
|
||||||
|
|
@ -5001,6 +5069,14 @@ def _parse_street_house(addr: str | None) -> tuple[str, str]:
|
||||||
# Ветка 2 (#2269 а/б): имя ПЕРЕД типом, тип-суффикс с пробелом или
|
# Ветка 2 (#2269 а/б): имя ПЕРЕД типом, тип-суффикс с пробелом или
|
||||||
# приклеенный запятой/точкой («Рассветная улица», «Рассветная ул.,6к1»).
|
# приклеенный запятой/точкой («Рассветная улица», «Рассветная ул.,6к1»).
|
||||||
m = _DEDUP_STREET_NAME_SUFFIX_RE.search(s)
|
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:
|
if m is None:
|
||||||
# Ветка 3 (#2269 в): без уличного типа — микрорайон-fallback. Токен
|
# Ветка 3 (#2269 в): без уличного типа — микрорайон-fallback. Токен
|
||||||
# получает префикс «mkr », чтобы не пересекаться с уличными токенами.
|
# получает префикс «mkr », чтобы не пересекаться с уличными токенами.
|
||||||
|
|
@ -5151,11 +5227,23 @@ def _dedup_cross_source(lots: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
union(prev, i, guard_house=False)
|
union(prev, i, guard_house=False)
|
||||||
|
|
||||||
# 2) street-композит — union при отсутствии cadnum- И house-конфликта.
|
# 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] = {}
|
street_owner: dict[tuple[str, Any, int, int], int] = {}
|
||||||
for i, (_cad_s, _house, _cad_key, street_key) in enumerate(comps):
|
for i, (_cad_s, _house, _cad_key, street_key) in enumerate(comps):
|
||||||
if street_key is None:
|
if street_key is None:
|
||||||
continue
|
continue
|
||||||
|
street, floor, area_b, price_b = street_key
|
||||||
prev = street_owner.get(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:
|
if prev is None:
|
||||||
street_owner[street_key] = i
|
street_owner[street_key] = i
|
||||||
else:
|
else:
|
||||||
|
|
|
||||||
|
|
@ -488,6 +488,37 @@ def test_dedup_collapses_domklik_cian_name_order(monkeypatch: pytest.MonkeyPatch
|
||||||
assert out[0]["source"] == "cian" # представитель — свежайший scraped_at
|
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:
|
def test_dedup_collapses_avito_glued_suffix(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
# (б) avito с глухим суффиксом «Рассветная ул.,6к1» + cian «улица Рассветная,
|
# (б) avito с глухим суффиксом «Рассветная ул.,6к1» + cian «улица Рассветная,
|
||||||
# 6к1» → тот же токен «рассветная», house не извлекается ни у одного (guard
|
# 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)
|
out = estimator._dedup_cross_source(lots)
|
||||||
assert len(out) == 2
|
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