fix(tradein/estimator): дедуп — нумерованные улицы + house-no guard на street-union (#2265, review follow-up)
Some checks failed
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 10s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Failing after 1m51s
Some checks failed
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 10s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Failing after 1m51s
This commit is contained in:
parent
9a9f13d513
commit
9cdc7cef21
2 changed files with 160 additions and 28 deletions
|
|
@ -4614,9 +4614,21 @@ _STREET_TYPE_WORDS = (
|
|||
)
|
||||
# Длинные типы раньше коротких, чтобы «улица» матчилось раньше «ул».
|
||||
_STREET_TYPE_ALT = "|".join(re.escape(w) for w in sorted(_STREET_TYPE_WORDS, key=len, reverse=True))
|
||||
# «<тип>[.] <название>»: название = 1-3 буквенных слова, до запятой/цифры.
|
||||
# «<тип>[.] <название>»: название = опциональная ведущая цифро-группа + 1-3
|
||||
# буквенных слова, до запятой/цифры. Ведущая цифра нужна для нумерованных улиц
|
||||
# («8 Марта», «40 лет Октября», «22 Партсъезда»); она разрешена ТОЛЬКО перед
|
||||
# буквенным словом (?:\d+\s+)?[а-яё]…, поэтому НОМЕР ДОМА (число ПОСЛЕ названия,
|
||||
# не сопровождаемое буквой) в токен не попадает: «Белинского 86» → «белинского».
|
||||
_STREET_NAME_RE = re.compile(
|
||||
rf"(?:^|[,\s])(?:{_STREET_TYPE_ALT})\.?\s+([а-яё][а-яё\-]+(?:\s+[а-яё][а-яё\-]+){{0,2}})",
|
||||
rf"(?:^|[,\s])(?:{_STREET_TYPE_ALT})\.?\s+"
|
||||
r"((?:\d+\s+)?[а-яё][а-яё\-]+(?:\s+[а-яё][а-яё\-]+){0,2})",
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
# Номер дома = первое число ПОСЛЕ названия улицы (опц. «д.»/«дом»), с литерой/
|
||||
# корпусом («46», «8а», «12/3»). Ищется в остатке строки от конца street-матча —
|
||||
# ведущая цифра нумерованной улицы («8 Марта») сюда не попадает.
|
||||
_HOUSE_NO_RE = re.compile(
|
||||
r"\b(?:д(?:ом)?\.?\s*)?(\d+(?:[а-яё]|/\d+)?)\b",
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
# Слова-маркеры дома, которые могли попасть в захват названия (напр. «ленина д 5»).
|
||||
|
|
@ -4643,49 +4655,63 @@ _ADDR_NOISE_WORDS = frozenset(
|
|||
)
|
||||
|
||||
|
||||
def _extract_street_token(addr: str | None) -> str:
|
||||
"""Нормализованный уличный токен для дедуп-ключа (#2265).
|
||||
def _parse_street_house(addr: str | None) -> tuple[str, str]:
|
||||
"""(street_token, house_no) из адреса произвольного source-формата (#2265).
|
||||
|
||||
Из адреса произвольного source-формата вытаскивает название улицы без типа
|
||||
(улица/пер/пр…), города, района и «Россия». Не распознан / пусто → "".
|
||||
street_token — название улицы без типа (улица/пер/пр…), города, района и
|
||||
«Россия»; нумерованные улицы («8 Марта») сохраняют ведущую цифру. house_no —
|
||||
первое число ПОСЛЕ названия улицы (может отсутствовать: cian/domklik часто
|
||||
без номера). Не распознано → ("", "").
|
||||
"""
|
||||
if not addr:
|
||||
return ""
|
||||
m = _STREET_NAME_RE.search(addr.strip().lower())
|
||||
return "", ""
|
||||
s = addr.strip().lower()
|
||||
m = _STREET_NAME_RE.search(s)
|
||||
if not m:
|
||||
return ""
|
||||
return "", ""
|
||||
words = [
|
||||
w
|
||||
for w in m.group(1).split()
|
||||
if w and w not in _HOUSE_MARKER_WORDS and w not in _ADDR_NOISE_WORDS
|
||||
]
|
||||
return " ".join(words).strip()
|
||||
street = " ".join(words).strip()
|
||||
house = ""
|
||||
hm = _HOUSE_NO_RE.search(s, m.end())
|
||||
if hm:
|
||||
house = hm.group(1)
|
||||
return street, house
|
||||
|
||||
|
||||
def _extract_street_token(addr: str | None) -> str:
|
||||
"""Нормализованный уличный токен для дедуп-ключа (#2265). См. _parse_street_house."""
|
||||
return _parse_street_house(addr)[0]
|
||||
|
||||
|
||||
def _lot_dedup_components(
|
||||
lot: dict[str, Any],
|
||||
) -> tuple[str, tuple[str, Any, int, int] | None, tuple[str, Any, int, int] | None]:
|
||||
"""(cadnum, cadnum-композит, street-композит) лота для union-find дедупа.
|
||||
) -> tuple[str, str, tuple[str, Any, int, int] | None, tuple[str, Any, int, int] | None]:
|
||||
"""(cadnum, house_no, cadnum-композит, street-композит) лота для union-find.
|
||||
|
||||
Каждый композит = (building, floor, area_bucket, price_bucket) или None,
|
||||
если нет соответствующего сигнала. area_bucket = round(area_m2) (~±0.5 м² —
|
||||
гасит 66.9 vs 67.0), price_bucket = round(price_rub / _DEDUP_PRICE_BUCKET_RUB)
|
||||
(у настоящего кросс-поста цена идентична, допуск лишь гасит округление).
|
||||
Нет площади/цены → оба None (лот уникален, не дедупится).
|
||||
house_no — extra-сигнал для street-union guard (не входит в equality-ключ).
|
||||
Нет площади/цены → композиты None (лот уникален, не дедупится).
|
||||
"""
|
||||
area = lot.get("area_m2")
|
||||
price = lot.get("price_rub")
|
||||
if not area or not price:
|
||||
return "", None, None
|
||||
return "", "", None, None
|
||||
floor = lot.get("floor")
|
||||
area_b = round(float(area))
|
||||
price_b = round(float(price) / _DEDUP_PRICE_BUCKET_RUB)
|
||||
cad = lot.get("building_cadastral_number")
|
||||
cad_s = str(cad).strip() if cad and str(cad).strip() else ""
|
||||
cad_key = (cad_s, floor, area_b, price_b) if cad_s else None
|
||||
street = _extract_street_token(lot.get("address"))
|
||||
street, house = _parse_street_house(lot.get("address"))
|
||||
street_key = (street, floor, area_b, price_b) if street else None
|
||||
return cad_s, cad_key, street_key
|
||||
return cad_s, house, cad_key, street_key
|
||||
|
||||
|
||||
def _phys_dedup_key(lot: dict[str, Any]) -> tuple[str, Any, int, int] | None:
|
||||
|
|
@ -4695,7 +4721,7 @@ def _phys_dedup_key(lot: dict[str, Any]) -> tuple[str, Any, int, int] | None:
|
|||
площади/цены или не из чего построить building. Сохраняет 4-кортежную форму
|
||||
(canonical-ключ; union-find в _dedup_cross_source использует оба композита).
|
||||
"""
|
||||
_cad_s, cad_key, street_key = _lot_dedup_components(lot)
|
||||
_cad_s, _house, cad_key, street_key = _lot_dedup_components(lot)
|
||||
return cad_key or street_key
|
||||
|
||||
|
||||
|
|
@ -4725,7 +4751,9 @@ def _dedup_cross_source(lots: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|||
|
||||
#2265: cadnum-композит обрабатывается первым (авторитетный), затем street-
|
||||
композит — но union по street НЕ сливает два РАЗНЫХ непустых кадастра
|
||||
(разные здания). Флаг OFF → no-op (байт-идентичный проход). Лоты без
|
||||
(разные здания) И два РАЗНЫХ извлечённых номера дома (equal-or-missing =
|
||||
сливаем, both-present-different = нет). Оба guard'а — на уровне корня группы,
|
||||
устойчивы к порядку. Флаг OFF → no-op (байт-идентичный проход). Лоты без
|
||||
площади/цены не дедупятся — остаются уникальными.
|
||||
"""
|
||||
if not settings.estimate_dedup_analogs_enabled or len(lots) < 2:
|
||||
|
|
@ -4734,8 +4762,9 @@ def _dedup_cross_source(lots: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|||
n = len(lots)
|
||||
parent = list(range(n))
|
||||
comps = [_lot_dedup_components(lot) for lot in lots]
|
||||
# cadnum группы (root → кадастр «» если группа без/со смешанным кадастром).
|
||||
# cadnum/house группы (root → «» если группа без/со смешанным значением).
|
||||
root_cad = [c[0] for c in comps]
|
||||
root_house = [c[1] for c in comps]
|
||||
|
||||
def find(x: int) -> int:
|
||||
while parent[x] != x:
|
||||
|
|
@ -4743,43 +4772,51 @@ def _dedup_cross_source(lots: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|||
x = parent[x]
|
||||
return x
|
||||
|
||||
def union(a: int, b: int) -> None:
|
||||
def union(a: int, b: int, guard_house: bool) -> None:
|
||||
ra, rb = find(a), find(b)
|
||||
if ra == rb:
|
||||
return
|
||||
ca, cb = root_cad[ra], root_cad[rb]
|
||||
if ca and cb and ca != cb:
|
||||
return # разные кадастры → разные здания, не сливаем
|
||||
merged = ca or cb
|
||||
ha, hb = root_house[ra], root_house[rb]
|
||||
if guard_house and ha and hb and ha != hb:
|
||||
return # разные номера дома (оба извлечены) → не сливаем по street
|
||||
merged_cad = ca or cb
|
||||
# both-present-different номер дома возможен лишь при cadnum-union
|
||||
# (guard_house=False) → помечаем группу как «смешанную» ("").
|
||||
merged_house = "" if (ha and hb and ha != hb) else (ha or hb)
|
||||
# меньший индекс — корень: сохраняет first-seen порядок групп.
|
||||
if ra < rb:
|
||||
parent[rb] = ra
|
||||
root_cad[ra] = merged
|
||||
root_cad[ra] = merged_cad
|
||||
root_house[ra] = merged_house
|
||||
else:
|
||||
parent[ra] = rb
|
||||
root_cad[rb] = merged
|
||||
root_cad[rb] = merged_cad
|
||||
root_house[rb] = merged_house
|
||||
|
||||
# 1) cadnum-композит — авторитетный (тот же кадастр = то же здание).
|
||||
cad_owner: dict[tuple[str, Any, int, int], int] = {}
|
||||
for i, (_cad_s, cad_key, _street_key) in enumerate(comps):
|
||||
for i, (_cad_s, _house, cad_key, _street_key) in enumerate(comps):
|
||||
if cad_key is None:
|
||||
continue
|
||||
prev = cad_owner.get(cad_key)
|
||||
if prev is None:
|
||||
cad_owner[cad_key] = i
|
||||
else:
|
||||
union(prev, i)
|
||||
union(prev, i, guard_house=False)
|
||||
|
||||
# 2) street-композит — union при отсутствии cadnum-конфликта.
|
||||
# 2) street-композит — union при отсутствии cadnum- И house-конфликта.
|
||||
street_owner: dict[tuple[str, Any, int, int], int] = {}
|
||||
for i, (_cad_s, _cad_key, street_key) in enumerate(comps):
|
||||
for i, (_cad_s, _house, _cad_key, street_key) in enumerate(comps):
|
||||
if street_key is None:
|
||||
continue
|
||||
prev = street_owner.get(street_key)
|
||||
if prev is None:
|
||||
street_owner[street_key] = i
|
||||
else:
|
||||
union(prev, i)
|
||||
union(prev, i, guard_house=True)
|
||||
|
||||
groups: dict[int, list[dict[str, Any]]] = {}
|
||||
order: list[int] = []
|
||||
|
|
|
|||
|
|
@ -304,3 +304,98 @@ def test_dedup_same_street_different_metrics_stay_distinct(
|
|||
]
|
||||
out = estimator._dedup_cross_source(lots)
|
||||
assert len(out) == 2
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# #2265 review follow-up — нумерованные улицы + house-no guard
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_parse_numbered_streets_and_house_no() -> None:
|
||||
# Нумерованная улица: ведущая цифра остаётся в токене, номер дома отдельно.
|
||||
assert estimator._parse_street_house("улица 8 Марта, 46") == ("8 марта", "46")
|
||||
assert estimator._parse_street_house("улица 40 лет Октября, 12") == ("40 лет октября", "12")
|
||||
assert estimator._parse_street_house("ул. 22 Партсъезда 7") == ("22 партсъезда", "7")
|
||||
# Обычная улица + номер дома: цифра ПОСЛЕ названия не попадает в токен.
|
||||
assert estimator._parse_street_house("ул. Белинского 86") == ("белинского", "86")
|
||||
assert estimator._parse_street_house("улица Сыромолотова, 11") == ("сыромолотова", "11")
|
||||
# Без номера дома → house пуст (cian/domklik часто так).
|
||||
assert estimator._parse_street_house("улица Сыромолотова") == ("сыромолотова", "")
|
||||
|
||||
|
||||
def test_extract_street_token_numbered_not_degraded() -> None:
|
||||
# Ведущая цифра нумерованной улицы разрешена, номер дома — нет.
|
||||
assert estimator._extract_street_token("улица 8 Марта, 46") == "8 марта"
|
||||
assert estimator._extract_street_token("ул. Белинского 86") == "белинского"
|
||||
|
||||
|
||||
def test_dedup_collapses_numbered_street_three_formats(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
# «8 Марта» в трёх source-форматах, cadnum пуст → один физлот.
|
||||
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
|
||||
lots = [
|
||||
_lot(
|
||||
source="cian",
|
||||
address="р-н Ленинский, улица 8 Марта, 46",
|
||||
area=52.0,
|
||||
price=7_800_000,
|
||||
floor=5,
|
||||
cad=None,
|
||||
),
|
||||
_lot(
|
||||
source="domklik",
|
||||
address="Россия, Екатеринбург, улица 8 Марта, 46",
|
||||
area=52.0,
|
||||
price=7_800_000,
|
||||
floor=5,
|
||||
cad=None,
|
||||
),
|
||||
_lot(
|
||||
source="yandex",
|
||||
address="Екатеринбург, улица 8 Марта, 46",
|
||||
area=52.0,
|
||||
price=7_800_000,
|
||||
floor=5,
|
||||
cad=None,
|
||||
),
|
||||
]
|
||||
out = estimator._dedup_cross_source(lots)
|
||||
assert len(out) == 1
|
||||
|
||||
|
||||
def test_dedup_house_no_guard_blocks_street_union(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
# Пустой cadnum, ОДНА улица/этаж/area/price, но РАЗНЫЕ извлечённые номера
|
||||
# дома → house-guard не даёт слить (both-present-different).
|
||||
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
|
||||
lots = [
|
||||
_lot(source="cian", address="улица Сыромолотова, 11", area=33.6, price=4_000_000, floor=3),
|
||||
_lot(source="avito", address="улица Сыромолотова, 13", area=33.6, price=4_000_000, floor=3),
|
||||
]
|
||||
out = estimator._dedup_cross_source(lots)
|
||||
assert len(out) == 2
|
||||
|
||||
|
||||
def test_dedup_house_no_guard_order_stable(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
# Устойчивость к порядку: домклик без номера сливается с одним из номеров,
|
||||
# два разных номера остаются раздельными независимо от позиции «пустого».
|
||||
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
|
||||
base = dict(area=33.6, price=4_000_000, floor=3, cad=None)
|
||||
lots = [
|
||||
_lot(source="domklik", address="улица Сыромолотова", **base), # house ""
|
||||
_lot(source="yandex", address="улица Сыромолотова, 11", **base), # house 11
|
||||
_lot(source="avito", address="улица Сыромолотова, 13", **base), # house 13
|
||||
]
|
||||
out = estimator._dedup_cross_source(lots)
|
||||
# 11 и 13 — разные дома → минимум 2 группы; «пустой» примыкает к одному из них.
|
||||
assert len(out) == 2
|
||||
|
||||
|
||||
def test_dedup_empty_cadnum_and_street_stay_separate(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
# Анти-катастрофа: пустой cadnum И нераспознанный адрес (нет типа улицы) при
|
||||
# идентичных метриках → НЕ сливаем (нет ни одного ключа → каждый уникален).
|
||||
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
|
||||
lots = [
|
||||
_lot(source="cian", address="ЖК Дружба", area=40.0, price=5_000_000, floor=6, cad=None),
|
||||
_lot(source="avito", address="ЖК Дружба", area=40.0, price=5_000_000, floor=6, cad=None),
|
||||
]
|
||||
out = estimator._dedup_cross_source(lots)
|
||||
assert len(out) == 2
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue