diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index a2f7fc9e..48cc5c8c 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -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] = [] 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 da38437f..ee905255 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 @@ -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