From a08e263c60cb247517af87f6cdaadb67223e7a49 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Fri, 3 Jul 2026 10:19:19 +0300 Subject: [PATCH 1/4] =?UTF-8?q?fix(tradein/estimator):=20=D0=BA=D1=80?= =?UTF-8?q?=D0=BE=D1=81=D1=81-source=20=D0=B4=D0=B5=D0=B4=D1=83=D0=BF=20?= =?UTF-8?q?=E2=80=94=20=D1=83=D1=81=D1=82=D0=BE=D0=B9=D1=87=D0=B8=D0=B2?= =?UTF-8?q?=D1=8B=D0=B9=20=D0=B0=D0=B4=D1=80=D0=B5=D1=81=D0=BD=D1=8B=D0=B9?= =?UTF-8?q?=20=D0=BA=D0=BB=D1=8E=D1=87=20street+house=20(#2265)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tradein-mvp/backend/app/services/estimator.py | 248 ++++++++++++++---- .../test_estimator_dedup_cross_source_2087.py | 125 +++++++++ 2 files changed, 323 insertions(+), 50 deletions(-) diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index f8488ede..a75304b3 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -4537,53 +4537,149 @@ def _apply_corridor_clamp( return new_ppm2, new_price, new_low, new_high, True -# ── #2087 H4: кросс-source физический дедуп аналогов ───────────────────────── +# ── #2087 H4 / #2265: кросс-source физический дедуп аналогов ────────────────── # Radius-дедуп (_RN_DUP_WINDOW) ловит только повторы ВНУТРИ одного source # (source, source_id). Один физический лот, кросс-запостенный на avito+cian+ # domklik, имеет РАЗНЫЕ source → переживает radius-дедуп → считается несколько # раз → раздувает n_analogs И cv (→ шире коридор), может смещать медиану. # _dedup_cross_source схлопывает такие дубли по физическому ключу ДО агрегации. +# +# #2265: адресный fallback-ключ ужесточён. Раньше building = lower + схлопнутая +# пунктуация ВСЕГО адреса → три source-формата одного дома дают три разных +# building-компонента и один физлот считается тремя. Прод-пример (оценка +# d5fc3841): один лот 33.6 м²/3 этаж/4.0М на ул. Сыромолотова показан как 3 +# аналога, т.к. cadnum пуст у всех троих, а адреса форматированы по-разному: +# cian «р-н Кировский, мкр. ЖБИ, улица Сыромолотова, …» +# domklik «Россия, Екатеринбург, ЖБИ м-н, улица Сыромолотова…» +# yandex «Екатеринбург, улица Сыромолотова, 11» +# domklik building_cadastral_number ВСЕГДА пуст (0/6296), yandex/avito/cian — +# 17/29/33% → cadnum-ключ между источниками тоже не совпадает. +# +# Фикс: из адреса извлекаем нормализованный street_token (тип улицы + город + +# район + «Россия» выброшены) и строим композит (street_token, floor, area, +# price). house_no СОЗНАТЕЛЬНО не входит в equality-ключ — он присутствует +# непоследовательно (yandex несёт номер, cian/domklik часто нет), включение +# помешало бы слить именно тот кросс-пост, ради которого дедуп существует. +# Разные физлоты остаются разными через floor/area/price (требование «не +# over-merge»). Дедуп union-find по ЛЮБОМУ совпавшему ключу (cadnum-композит ИЛИ +# street-композит) → закрывает и «у одного cadnum есть, у другого нет, адрес +# совпадает». Два РАЗНЫХ кадастра (авторитетный сигнал разных зданий) НЕ +# сливаются, даже если street-композит совпал. _DEDUP_PRICE_BUCKET_RUB = 100_000 -_ADDR_NORM_RE = re.compile(r"[^0-9a-zа-яё]+", flags=re.IGNORECASE) + +# Типы улиц (для отсечения от названия). District-маркеры (мкр/м-н/квартал/ +# район) СЮДА НЕ входят — иначе «мкр. ЖБИ, улица Сыромолотова» матчилось бы на +# «мкр ЖБИ» вместо реальной улицы; они убираются как шум в _ADDR_NOISE_WORDS. +_STREET_TYPE_WORDS = ( + "улица", + "ул", + "переулок", + "пер", + "проспект", + "просп", + "пр-кт", + "пр-т", + "пр", + "бульвар", + "б-р", + "бул", + "проезд", + "шоссе", + "ш", + "набережная", + "наб", + "площадь", + "пл", + "аллея", + "тупик", + "тракт", + "линия", +) +# Длинные типы раньше коротких, чтобы «улица» матчилось раньше «ул». +_STREET_TYPE_ALT = "|".join(re.escape(w) for w in sorted(_STREET_TYPE_WORDS, key=len, reverse=True)) +# «<тип>[.] <название>»: название = 1-3 буквенных слова, до запятой/цифры. +_STREET_NAME_RE = re.compile( + rf"(?:^|[,\s])(?:{_STREET_TYPE_ALT})\.?\s+([а-яё][а-яё\-]+(?:\s+[а-яё][а-яё\-]+){{0,2}})", + flags=re.IGNORECASE, +) +# Слова-маркеры дома, которые могли попасть в захват названия (напр. «ленина д 5»). +_HOUSE_MARKER_WORDS = frozenset( + {"дом", "д", "корпус", "корп", "к", "строение", "стр", "литера", "лит", "владение", "вл"} +) +# Шум-токены (город/страна/район/микрорайон) на случай, если попали в название. +_ADDR_NOISE_WORDS = frozenset( + { + "россия", + "рф", + "екатеринбург", + "г", + "город", + "область", + "обл", + "район", + "р-н", + "мкр", + "м-н", + "микрорайон", + "квартал", + } +) -def _norm_addr_for_dedup(addr: str | None) -> str: - """Нормализация адреса для физического ключа: lower + схлопнутая пунктуация. +def _extract_street_token(addr: str | None) -> str: + """Нормализованный уличный токен для дедуп-ключа (#2265). - Кросс-source адреса форматируются по-разному («ул. Ленина, д.5» vs - «Ленина 5») — нормализуем к сравнимому виду. Пустой/None → "". + Из адреса произвольного source-формата вытаскивает название улицы без типа + (улица/пер/пр…), города, района и «Россия». Не распознан / пусто → "". """ if not addr: return "" - return _ADDR_NORM_RE.sub(" ", addr.strip().lower()).strip() + m = _STREET_NAME_RE.search(addr.strip().lower()) + if not m: + 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() -def _phys_dedup_key(lot: dict[str, Any]) -> tuple[str, Any, int, int] | None: - """Физический ключ лота (building, floor, area_bucket, price_bucket). +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 дедупа. - None, если нет данных для ключа (нет площади/цены/building) — такой лот - НЕ дедупим (оставляем как уникальный, чтобы не сливать неоднозначное). - building = building_cadastral_number (надёжнее) ИЛИ нормализованный address. - area_bucket = round(area_m2) (~±0.5 м² — поглощает округление 66.9 vs 67.0). - price_bucket = round(price_rub / _DEDUP_PRICE_BUCKET_RUB): у настоящего - кросс-поста цена идентична (тот же продавец), допуск лишь гасит округление. + Каждый композит = (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 (лот уникален, не дедупится). """ area = lot.get("area_m2") price = lot.get("price_rub") if not area or not price: - return 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") - building = ( - str(cad).strip() if cad and str(cad).strip() else _norm_addr_for_dedup(lot.get("address")) - ) - if not building: - return None - return ( - building, - lot.get("floor"), - round(float(area)), - round(float(price) / _DEDUP_PRICE_BUCKET_RUB), - ) + 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_key = (street, floor, area_b, price_b) if street else None + return cad_s, cad_key, street_key + + +def _phys_dedup_key(lot: dict[str, Any]) -> tuple[str, Any, int, int] | None: + """Первичный физический ключ (building, floor, area_bucket, price_bucket). + + building = cadnum (надёжнее) ИЛИ street_token (#2265). None, если нет + площади/цены или не из чего построить building. Сохраняет 4-кортежную форму + (canonical-ключ; union-find в _dedup_cross_source использует оба композита). + """ + _cad_s, cad_key, street_key = _lot_dedup_components(lot) + return cad_key or street_key def _dedup_rep_key(lot: dict[str, Any]) -> tuple[float, int, str, str]: @@ -4599,36 +4695,88 @@ def _dedup_rep_key(lot: dict[str, Any]) -> tuple[float, int, str, str]: def _dedup_cross_source(lots: list[dict[str, Any]]) -> list[dict[str, Any]]: - """Схлопывает кросс-source дубли одного физического лота (#2087 H4). + """Схлопывает кросс-source дубли одного физического лота (#2087 H4 / #2265). - Из группы с общим физическим ключом (_phys_dedup_key) оставляет ОДНОГО - представителя (свежайший scraped_at), НЕ суммирует. Порядок вывода — по - первому появлению группы (сохраняет relevance-сортировку из _fetch_analogs - для top-N UI). Представитель несёт свой source → n_analogs/median/cv/ - source_counts/sources_used считаются по физическим лотам («лот считается - один раз»; source кросс-поста, не ставший представителем, выпадает из - выборки — по нему нет независимой ценовой точки). Флаг OFF → no-op - (байт-идентичный проход). Лоты без ключа (нет площади/цены/building) не - дедупятся — остаются уникальными. + Union-find по физическим композитам: два лота — дубли, если совпал ЛЮБОЙ + ключ — cadnum-композит ИЛИ street-композит (_lot_dedup_components). Из группы + оставляет ОДНОГО представителя (свежайший scraped_at), НЕ суммирует. Порядок + вывода — по первому появлению группы (сохраняет relevance-сортировку из + _fetch_analogs для top-N UI). Представитель несёт свой source → n_analogs/ + median/cv/source_counts/sources_used считаются по физическим лотам («лот + считается один раз»; source кросс-поста, не ставший представителем, выпадает + из выборки — по нему нет независимой ценовой точки). + + #2265: cadnum-композит обрабатывается первым (авторитетный), затем street- + композит — но union по street НЕ сливает два РАЗНЫХ непустых кадастра + (разные здания). Флаг OFF → no-op (байт-идентичный проход). Лоты без + площади/цены не дедупятся — остаются уникальными. """ if not settings.estimate_dedup_analogs_enabled or len(lots) < 2: return lots - groups: dict[Any, list[dict[str, Any]]] = {} - order: list[Any] = [] - for lot in lots: - key: Any = _phys_dedup_key(lot) - if key is None: - key = ("__nodup__", id(lot)) # неоднозначный → уникальный псевдоключ - if key not in groups: - groups[key] = [] - order.append(key) - groups[key].append(lot) + n = len(lots) + parent = list(range(n)) + comps = [_lot_dedup_components(lot) for lot in lots] + # cadnum группы (root → кадастр «» если группа без/со смешанным кадастром). + root_cad = [c[0] for c in comps] + + def find(x: int) -> int: + while parent[x] != x: + parent[x] = parent[parent[x]] # path-halving + x = parent[x] + return x + + def union(a: int, b: int) -> 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 + # меньший индекс — корень: сохраняет first-seen порядок групп. + if ra < rb: + parent[rb] = ra + root_cad[ra] = merged + else: + parent[ra] = rb + root_cad[rb] = merged + + # 1) cadnum-композит — авторитетный (тот же кадастр = то же здание). + cad_owner: dict[tuple[str, Any, int, int], int] = {} + for i, (_cad_s, 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) + + # 2) street-композит — union при отсутствии cadnum-конфликта. + street_owner: dict[tuple[str, Any, int, int], int] = {} + for i, (_cad_s, _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) + + groups: dict[int, list[dict[str, Any]]] = {} + order: list[int] = [] + for i, lot in enumerate(lots): + root = find(i) + if root not in groups: + groups[root] = [] + order.append(root) + groups[root].append(lot) result: list[dict[str, Any]] = [] collapsed = 0 - for key in order: - group = groups[key] + for root in order: + group = groups[root] if len(group) == 1: result.append(group[0]) else: @@ -4636,7 +4784,7 @@ def _dedup_cross_source(lots: list[dict[str, Any]]) -> list[dict[str, Any]]: collapsed += len(group) - 1 if collapsed: logger.info( - "cross-source dedup #2087 H4: %d lots → %d unique (-%d dups)", + "cross-source dedup #2087/#2265: %d lots → %d unique (-%d dups)", len(lots), len(result), collapsed, 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 725922d4..da38437f 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 @@ -179,3 +179,128 @@ def test_dedup_lots_without_key_stay_unique(monkeypatch: pytest.MonkeyPatch) -> ] out = estimator._dedup_cross_source(lots) assert len(out) == 2 + + +# --------------------------------------------------------------------------- # +# #2265 — устойчивый адресный street-токен (кросс-source форматы) +# --------------------------------------------------------------------------- # + + +def test_extract_street_token_across_source_formats() -> None: + # Три РЕАЛЬНЫХ прод-формата одного дома (ул. Сыромолотова) → один токен. + cian = estimator._extract_street_token("р-н Кировский, мкр. ЖБИ, улица Сыромолотова, 11") + domklik = estimator._extract_street_token("Россия, Екатеринбург, ЖБИ м-н, улица Сыромолотова") + yandex = estimator._extract_street_token("Екатеринбург, улица Сыромолотова, 11") + assert cian == domklik == yandex == "сыромолотова" + + +def test_extract_street_token_strips_house_and_noise() -> None: + assert estimator._extract_street_token("Екатеринбург, ул. Ленина, д. 5") == "ленина" + assert estimator._extract_street_token("проспект Космонавтов 15") == "космонавтов" + assert estimator._extract_street_token("пер. Банковский, 8а") == "банковский" + # Нет типа улицы → распознать нельзя → пусто (безопасный fallback, лот уникален). + assert estimator._extract_street_token("Екатеринбург, ЖК Дружба") == "" + assert estimator._extract_street_token("") == "" + assert estimator._extract_street_token(None) == "" + + +def test_dedup_collapses_three_source_formats(monkeypatch: pytest.MonkeyPatch) -> None: + # Прод-дефект (оценка d5fc3841): один физлот 33.6 м²/этаж 3/4.0М показан + # как 3 аналога — cadnum пуст у всех троих, адреса в трёх форматах. + monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True) + lots = [ + _lot( + source="cian", + address="р-н Кировский, мкр. ЖБИ, улица Сыромолотова, 11", + area=33.6, + price=4_000_000, + floor=3, + cad=None, + ), + _lot( + source="domklik", + address="Россия, Екатеринбург, ЖБИ м-н, улица Сыромолотова", + area=33.6, + price=4_000_000, + floor=3, + cad=None, + ), + _lot( + source="yandex", + address="Екатеринбург, улица Сыромолотова, 11", + area=33.6, + price=4_000_000, + floor=3, + cad=None, + ), + ] + out = estimator._dedup_cross_source(lots) + assert len(out) == 1 # один физлот, не три + + +def test_dedup_merges_cadnum_asymmetry_same_street(monkeypatch: pytest.MonkeyPatch) -> None: + # У одного cadnum есть, у другого нет, адрес совпадает → сливаются по + # street-композиту (#2265: union по любому совпавшему ключу). + monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True) + lots = [ + _lot( + source="cian", + address="улица Сыромолотова, 11", + area=33.6, + price=4_000_000, + floor=3, + cad="66:41:0601011:1234", + ), + _lot( + source="domklik", + address="ЖБИ м-н, улица Сыромолотова", + area=33.6, + price=4_000_000, + floor=3, + cad=None, + ), + ] + out = estimator._dedup_cross_source(lots) + assert len(out) == 1 + + +def test_dedup_distinct_cadnums_same_metrics_do_not_merge( + monkeypatch: pytest.MonkeyPatch, +) -> None: + # Две башни на одной улице с одинаковыми floor/area/price, но РАЗНЫМИ + # кадастрами — авторитетный сигнал разных зданий → НЕ сливаем. + monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True) + lots = [ + _lot( + source="cian", + address="улица Сыромолотова, 11", + area=33.6, + price=4_000_000, + floor=3, + cad="66:41:0601011:1234", + ), + _lot( + source="avito", + address="улица Сыромолотова, 13", + area=33.6, + price=4_000_000, + floor=3, + cad="66:41:0601011:9999", + ), + ] + out = estimator._dedup_cross_source(lots) + assert len(out) == 2 + + +def test_dedup_same_street_different_metrics_stay_distinct( + monkeypatch: pytest.MonkeyPatch, +) -> None: + # Разные лоты на одной улице с разной ценой/площадью/этажом → остаются + # разными (защита от over-merge при отсутствии cadnum). + 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="улица Сыромолотова, 11", area=45.0, price=6_500_000, floor=8), + ] + out = estimator._dedup_cross_source(lots) + assert len(out) == 2 From 9a9f13d513f31593f8fd248c52a4df7a8a0235cb Mon Sep 17 00:00:00 2001 From: bot-backend Date: Fri, 3 Jul 2026 10:19:57 +0300 Subject: [PATCH 2/4] =?UTF-8?q?fix(tradein/estimator):=20savepoint+retry?= =?UTF-8?q?=20=D0=B2=D0=BE=D0=BA=D1=80=D1=83=D0=B3=20ratio-lookup=20?= =?UTF-8?q?=E2=80=94=20transient=20=D0=BD=D0=B5=20=D0=B3=D0=B0=D1=81=D0=B8?= =?UTF-8?q?=D1=82=20expected=5Fsold=20(#2265)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tradein-mvp/backend/app/services/estimator.py | 95 +++++++++------- .../test_asking_sold_ratio_cache_poison.py | 11 ++ ...st_estimator_ratio_savepoint_retry_2265.py | 103 ++++++++++++++++++ 3 files changed, 170 insertions(+), 39 deletions(-) create mode 100644 tradein-mvp/backend/tests/test_estimator_ratio_savepoint_retry_2265.py diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index a75304b3..a2f7fc9e 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -263,46 +263,63 @@ def _get_asking_sold_ratio( ratio: float | None = None basis: str | None = None - try: - row = db.execute( - text( - """ - SELECT ratio, basis FROM asking_to_sold_ratios - WHERE rooms_bucket = CAST(:b AS int) AND district = '' - """ - ), - {"b": bucket}, - ).fetchone() - if row is None: - # Бакет тонкий (n<30 при seed'е) или отсутствует → global fallback (-1). - row = db.execute( - text( - """ - SELECT ratio, basis FROM asking_to_sold_ratios - WHERE rooms_bucket = -1 AND district = '' - """ - ), - ).fetchone() - if row is not None and row.ratio is not None: - ratio = float(row.ratio) - basis = row.basis - except Exception as exc: - # Таблицы может не быть на свежей/старой БД (миграция 080 не применена), - # либо транзакция в сбойном состоянии — graceful: без sold-коррекции. - # ОБЯЗАТЕЛЬНО rollback: неудачный SELECT помечает транзакцию - # InFailedSqlTransaction, и без отката следующий statement упал бы → 500. - # - # НЕ кэшируем этот None: ошибка транзиентна (poisoned tx от вышестоящего - # graceful-except, миг. лаг, коннект-хиккап). Раньше строка кэша ниже - # писалась безусловно → один сбой отравлял _asking_sold_ratio_cache[bucket] - # = (None, None) на весь TTL (300с) и молча гасил expected_sold («ожидаемая - # цена сделки» → «—») для ВСЕХ оценок этого rooms-бакета на воркере до - # истечения TTL. Ранний return без записи в кэш → следующая оценка ретраит. - logger.debug("asking_to_sold_ratio lookup skipped (graceful, NOT cached): %s", exc) + # #2265 D2: SAVEPOINT + 1 retry вокруг ratio-lookup. Транзиентный сбой — + # обычно poisoned tx (InFailedSqlTransaction) от вышестоящего graceful-except: + # первый же SELECT падает, и раньше это гасило expected_sold для КОНКРЕТНОЙ + # оценки (3/224 за 14 дней при живых ratio-бакетах). Оборачиваем в begin_nested: + # при сбое savepoint откатывается, tx чистится (db.rollback), retry идёт на + # здоровом соединении. begin_nested также локализует benign-сбой (таблицы нет + # на свежей/старой БД, миграция 080 не применена) — без отравления outer tx. + last_exc: Exception | None = None + ok = False + for attempt in range(2): try: - db.rollback() - except Exception: - pass + with db.begin_nested(): + row = db.execute( + text( + """ + SELECT ratio, basis FROM asking_to_sold_ratios + WHERE rooms_bucket = CAST(:b AS int) AND district = '' + """ + ), + {"b": bucket}, + ).fetchone() + if row is None: + # Бакет тонкий (n<30 при seed'е) или отсутствует → global (-1). + row = db.execute( + text( + """ + SELECT ratio, basis FROM asking_to_sold_ratios + WHERE rooms_bucket = -1 AND district = '' + """ + ), + ).fetchone() + if row is not None and row.ratio is not None: + ratio = float(row.ratio) + basis = row.basis + ok = True + break + except Exception as exc: + last_exc = exc + # begin_nested откатывает СВОЙ savepoint; но если outer tx уже была + # poisoned до savepoint — сам SAVEPOINT не встаёт → чистим всю tx, + # чтобы retry шёл на здоровом соединении. + try: + db.rollback() + except Exception: + pass + if attempt == 0: + logger.debug("asking_to_sold_ratio lookup failed, retrying on clean tx: %s", exc) + continue + + if not ok: + # Оба захода упали — graceful без sold-коррекции. НЕ кэшируем этот None: + # ошибка транзиентна (poisoned tx, миг. лаг, коннект-хиккап). Раньше строка + # кэша ниже писалась безусловно → один сбой отравлял + # _asking_sold_ratio_cache[bucket] = (None, None) на весь TTL (300с) и молча + # гасил expected_sold для ВСЕХ оценок бакета до истечения TTL (#2175). Ранний + # return без записи в кэш → следующая оценка ретраит. + logger.debug("asking_to_sold_ratio lookup skipped (graceful, NOT cached): %s", last_exc) return None, None # Кэшируем ТОЛЬКО успешный lookup. ratio может быть None (строки нет — diff --git a/tradein-mvp/backend/tests/test_asking_sold_ratio_cache_poison.py b/tradein-mvp/backend/tests/test_asking_sold_ratio_cache_poison.py index b717d562..1a1b7222 100644 --- a/tradein-mvp/backend/tests/test_asking_sold_ratio_cache_poison.py +++ b/tradein-mvp/backend/tests/test_asking_sold_ratio_cache_poison.py @@ -12,6 +12,8 @@ No DB: fake-Session (monkeypatched .execute/.rollback). DATABASE_URL нужен """ import os +from collections.abc import Iterator +from contextlib import contextmanager from types import SimpleNamespace from typing import Any @@ -37,6 +39,11 @@ class _OkDB: self._row = SimpleNamespace(ratio=ratio, basis=basis) self.rolled_back = False + @contextmanager + def begin_nested(self) -> Iterator[Any]: + # #2265 D2: ratio-lookup обёрнут в SAVEPOINT — моделируем CM. + yield SimpleNamespace() + def execute(self, *_a: Any, **_k: Any) -> _Result: return _Result(self._row) @@ -50,6 +57,10 @@ class _BoomDB: def __init__(self) -> None: self.rolled_back = False + @contextmanager + def begin_nested(self) -> Iterator[Any]: + yield SimpleNamespace() + def execute(self, *_a: Any, **_k: Any) -> _Result: raise RuntimeError("InFailedSqlTransaction") diff --git a/tradein-mvp/backend/tests/test_estimator_ratio_savepoint_retry_2265.py b/tradein-mvp/backend/tests/test_estimator_ratio_savepoint_retry_2265.py new file mode 100644 index 00000000..4dfcc76e --- /dev/null +++ b/tradein-mvp/backend/tests/test_estimator_ratio_savepoint_retry_2265.py @@ -0,0 +1,103 @@ +"""Unit tests for SAVEPOINT + retry вокруг ratio-lookup (#2265 D2). + +Транзиентный сбой первого ratio-SELECT (обычно poisoned tx, +InFailedSqlTransaction, от вышестоящего graceful-except) раньше гасил +expected_sold для КОНКРЕТНОЙ оценки (3/224 за 14 дней при живых бакетах). +Фикс: begin_nested + 1 retry на чистом savepoint. Проверяем: + - первый lookup падает → второй проходит → возвращается ratio (recover); + - оба захода падают → (None, None) и кэш НЕ отравлен (#2175 держится); + - happy-path — ровно один begin_nested, без rollback. + +No DB / network: session — лёгкий fake с side-effect'ами на execute. +DATABASE_URL нужен только для импорта app.core.config.Settings. +""" + +import os + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +from contextlib import contextmanager +from typing import Any +from unittest.mock import MagicMock + +from app.services import estimator + + +class _Row: + """Строка результата с полями .ratio/.basis (как row из fetchone()).""" + + def __init__(self, ratio: float | None, basis: str | None) -> None: + self.ratio = ratio + self.basis = basis + + +class _FakeSession: + """Session-заглушка: execute отдаёт side-effect'ы по очереди. + + Exception в списке → execute бросает; иначе — result, чей fetchone() вернёт + заготовленную строку. begin_nested — CM, rollback — счётчик. + """ + + def __init__(self, execute_effects: list[Any]) -> None: + self._effects = list(execute_effects) + self.rollback_count = 0 + self.begin_nested_count = 0 + + @contextmanager + def begin_nested(self): # type: ignore[no-untyped-def] + self.begin_nested_count += 1 + yield MagicMock() + + def execute(self, *_args: Any, **_kwargs: Any) -> Any: + eff = self._effects.pop(0) + if isinstance(eff, Exception): + raise eff + result = MagicMock() + result.fetchone.return_value = eff + return result + + def rollback(self) -> None: + self.rollback_count += 1 + + +def test_ratio_retry_recovers_after_first_lookup_fails() -> None: + estimator._asking_sold_ratio_cache.clear() + poisoned = RuntimeError("current transaction is aborted (InFailedSqlTransaction)") + db = _FakeSession([poisoned, _Row(0.84, "per_rooms")]) + + ratio, basis = estimator._get_asking_sold_ratio(db, rooms=1) # type: ignore[arg-type] + + assert ratio == 0.84 + assert basis == "per_rooms" + assert db.begin_nested_count == 2 # первый заход упал, второй прошёл + assert db.rollback_count >= 1 # чистка tx перед retry + assert 1 in estimator._asking_sold_ratio_cache # успех кэшируется + + +def test_ratio_both_attempts_fail_returns_none_and_not_cached() -> None: + estimator._asking_sold_ratio_cache.clear() + db = _FakeSession([RuntimeError("boom-1"), RuntimeError("boom-2")]) + + ratio, basis = estimator._get_asking_sold_ratio(db, rooms=2) # type: ignore[arg-type] + + assert ratio is None + assert basis is None + assert 2 not in estimator._asking_sold_ratio_cache # транзиент НЕ отравляет кэш (#2175) + assert db.begin_nested_count == 2 + assert db.rollback_count >= 2 + + +def test_ratio_happy_path_single_attempt_no_rollback() -> None: + estimator._asking_sold_ratio_cache.clear() + db = _FakeSession([_Row(0.9, "global_fallback")]) + + ratio, basis = estimator._get_asking_sold_ratio(db, rooms=0) # type: ignore[arg-type] + + assert ratio == 0.9 + assert basis == "global_fallback" + assert db.begin_nested_count == 1 # ни одного retry + assert db.rollback_count == 0 + cached = estimator._asking_sold_ratio_cache.get(0) + assert cached is not None + assert cached[0] == 0.9 + assert cached[1] == "global_fallback" From 9cdc7cef2126b7ce98e440a700bcbaf613bb043c Mon Sep 17 00:00:00 2001 From: bot-backend Date: Fri, 3 Jul 2026 10:37:26 +0300 Subject: [PATCH 3/4] =?UTF-8?q?fix(tradein/estimator):=20=D0=B4=D0=B5?= =?UTF-8?q?=D0=B4=D1=83=D0=BF=20=E2=80=94=20=D0=BD=D1=83=D0=BC=D0=B5=D1=80?= =?UTF-8?q?=D0=BE=D0=B2=D0=B0=D0=BD=D0=BD=D1=8B=D0=B5=20=D1=83=D0=BB=D0=B8?= =?UTF-8?q?=D1=86=D1=8B=20+=20house-no=20guard=20=D0=BD=D0=B0=20street-uni?= =?UTF-8?q?on=20(#2265,=20review=20follow-up)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tradein-mvp/backend/app/services/estimator.py | 93 ++++++++++++------ .../test_estimator_dedup_cross_source_2087.py | 95 +++++++++++++++++++ 2 files changed, 160 insertions(+), 28 deletions(-) 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 From 6f81ee73d7e2674dfc11a1d9532b1510f0948c62 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Fri, 3 Jul 2026 10:48:15 +0300 Subject: [PATCH 4/4] =?UTF-8?q?fix(tradein/estimator):=20=D0=BF=D0=BE?= =?UTF-8?q?=D0=BB=D0=BD=D1=8B=D0=B9=20=D1=81=D1=8C=D1=8E=D1=82=20=D0=B7?= =?UTF-8?q?=D0=B5=D0=BB=D1=91=D0=BD=D1=8B=D0=B9=20=D0=BF=D0=BE=D1=81=D0=BB?= =?UTF-8?q?=D0=B5=20=D0=B4=D0=B5=D0=B4=D1=83=D0=BF-=D0=BA=D0=BB=D1=8E?= =?UTF-8?q?=D1=87=D0=B0=20(#2265)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tradein-mvp/backend/app/services/estimator.py | 26 +++++++++++-------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index 48cc5c8c..cf506ff1 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -4586,8 +4586,10 @@ _DEDUP_PRICE_BUCKET_RUB = 100_000 # Типы улиц (для отсечения от названия). District-маркеры (мкр/м-н/квартал/ # район) СЮДА НЕ входят — иначе «мкр. ЖБИ, улица Сыромолотова» матчилось бы на -# «мкр ЖБИ» вместо реальной улицы; они убираются как шум в _ADDR_NOISE_WORDS. -_STREET_TYPE_WORDS = ( +# «мкр ЖБИ» вместо реальной улицы; они убираются как шум в _DEDUP_ADDR_NOISE_WORDS. +# NB: имена с префиксом _DEDUP_ — чтобы НЕ конфликтовать с модульным +# _STREET_NAME_RE (extract_street_name, другой парсер выше). +_DEDUP_STREET_TYPE_WORDS = ( "улица", "ул", "переулок", @@ -4613,30 +4615,32 @@ _STREET_TYPE_WORDS = ( "линия", ) # Длинные типы раньше коротких, чтобы «улица» матчилось раньше «ул». -_STREET_TYPE_ALT = "|".join(re.escape(w) for w in sorted(_STREET_TYPE_WORDS, key=len, reverse=True)) +_DEDUP_STREET_TYPE_ALT = "|".join( + re.escape(w) for w in sorted(_DEDUP_STREET_TYPE_WORDS, key=len, reverse=True) +) # «<тип>[.] <название>»: название = опциональная ведущая цифро-группа + 1-3 # буквенных слова, до запятой/цифры. Ведущая цифра нужна для нумерованных улиц # («8 Марта», «40 лет Октября», «22 Партсъезда»); она разрешена ТОЛЬКО перед # буквенным словом (?:\d+\s+)?[а-яё]…, поэтому НОМЕР ДОМА (число ПОСЛЕ названия, # не сопровождаемое буквой) в токен не попадает: «Белинского 86» → «белинского». -_STREET_NAME_RE = re.compile( - rf"(?:^|[,\s])(?:{_STREET_TYPE_ALT})\.?\s+" +_DEDUP_STREET_NAME_RE = re.compile( + rf"(?:^|[,\s])(?:{_DEDUP_STREET_TYPE_ALT})\.?\s+" r"((?:\d+\s+)?[а-яё][а-яё\-]+(?:\s+[а-яё][а-яё\-]+){0,2})", flags=re.IGNORECASE, ) # Номер дома = первое число ПОСЛЕ названия улицы (опц. «д.»/«дом»), с литерой/ # корпусом («46», «8а», «12/3»). Ищется в остатке строки от конца street-матча — # ведущая цифра нумерованной улицы («8 Марта») сюда не попадает. -_HOUSE_NO_RE = re.compile( +_DEDUP_HOUSE_NO_RE = re.compile( r"\b(?:д(?:ом)?\.?\s*)?(\d+(?:[а-яё]|/\d+)?)\b", flags=re.IGNORECASE, ) # Слова-маркеры дома, которые могли попасть в захват названия (напр. «ленина д 5»). -_HOUSE_MARKER_WORDS = frozenset( +_DEDUP_HOUSE_MARKER_WORDS = frozenset( {"дом", "д", "корпус", "корп", "к", "строение", "стр", "литера", "лит", "владение", "вл"} ) # Шум-токены (город/страна/район/микрорайон) на случай, если попали в название. -_ADDR_NOISE_WORDS = frozenset( +_DEDUP_ADDR_NOISE_WORDS = frozenset( { "россия", "рф", @@ -4666,17 +4670,17 @@ def _parse_street_house(addr: str | None) -> tuple[str, str]: if not addr: return "", "" s = addr.strip().lower() - m = _STREET_NAME_RE.search(s) + m = _DEDUP_STREET_NAME_RE.search(s) if not m: 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 + if w and w not in _DEDUP_HOUSE_MARKER_WORDS and w not in _DEDUP_ADDR_NOISE_WORDS ] street = " ".join(words).strip() house = "" - hm = _HOUSE_NO_RE.search(s, m.end()) + hm = _DEDUP_HOUSE_NO_RE.search(s, m.end()) if hm: house = hm.group(1) return street, house