Merge pull request 'fix(tradein/estimator): кросс-source дедуп street+house union-find + savepoint вокруг ratio-lookup (#2265)' (#2273) from fix/tradein-dedup-addr-key into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
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 1m35s
Deploy Trade-In / build-backend (push) Successful in 59s
Deploy Trade-In / deploy (push) Successful in 47s

This commit is contained in:
lekss361 2026-07-03 07:51:47 +00:00
commit 039954580e
4 changed files with 630 additions and 90 deletions

View file

@ -263,46 +263,63 @@ def _get_asking_sold_ratio(
ratio: float | None = None ratio: float | None = None
basis: str | None = None basis: str | None = None
try: # #2265 D2: SAVEPOINT + 1 retry вокруг ratio-lookup. Транзиентный сбой —
row = db.execute( # обычно poisoned tx (InFailedSqlTransaction) от вышестоящего graceful-except:
text( # первый же SELECT падает, и раньше это гасило expected_sold для КОНКРЕТНОЙ
""" # оценки (3/224 за 14 дней при живых ratio-бакетах). Оборачиваем в begin_nested:
SELECT ratio, basis FROM asking_to_sold_ratios # при сбое savepoint откатывается, tx чистится (db.rollback), retry идёт на
WHERE rooms_bucket = CAST(:b AS int) AND district = '' # здоровом соединении. begin_nested также локализует benign-сбой (таблицы нет
""" # на свежей/старой БД, миграция 080 не применена) — без отравления outer tx.
), last_exc: Exception | None = None
{"b": bucket}, ok = False
).fetchone() for attempt in range(2):
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)
try: try:
db.rollback() with db.begin_nested():
except Exception: row = db.execute(
pass 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 return None, None
# Кэшируем ТОЛЬКО успешный lookup. ratio может быть None (строки нет — # Кэшируем ТОЛЬКО успешный lookup. ratio может быть None (строки нет —
@ -4537,53 +4554,179 @@ def _apply_corridor_clamp(
return new_ppm2, new_price, new_low, new_high, True return new_ppm2, new_price, new_low, new_high, True
# ── #2087 H4: кросс-source физический дедуп аналогов ───────────────────────── # ── #2087 H4 / #2265: кросс-source физический дедуп аналогов ──────────────────
# Radius-дедуп (_RN_DUP_WINDOW) ловит только повторы ВНУТРИ одного source # Radius-дедуп (_RN_DUP_WINDOW) ловит только повторы ВНУТРИ одного source
# (source, source_id). Один физический лот, кросс-запостенный на avito+cian+ # (source, source_id). Один физический лот, кросс-запостенный на avito+cian+
# domklik, имеет РАЗНЫЕ source → переживает radius-дедуп → считается несколько # domklik, имеет РАЗНЫЕ source → переживает radius-дедуп → считается несколько
# раз → раздувает n_analogs И cv (→ шире коридор), может смещать медиану. # раз → раздувает n_analogs И cv (→ шире коридор), может смещать медиану.
# _dedup_cross_source схлопывает такие дубли по физическому ключу ДО агрегации. # _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 _DEDUP_PRICE_BUCKET_RUB = 100_000
_ADDR_NORM_RE = re.compile(r"[^0-9a-zа-яё]+", flags=re.IGNORECASE)
# Типы улиц (для отсечения от названия). District-маркеры (мкр/м-н/квартал/
# район) СЮДА НЕ входят — иначе «мкр. ЖБИ, улица Сыромолотова» матчилось бы на
# «мкр ЖБИ» вместо реальной улицы; они убираются как шум в _DEDUP_ADDR_NOISE_WORDS.
# NB: имена с префиксом _DEDUP_ — чтобы НЕ конфликтовать с модульным
# _STREET_NAME_RE (extract_street_name, другой парсер выше).
_DEDUP_STREET_TYPE_WORDS = (
"улица",
"ул",
"переулок",
"пер",
"проспект",
"просп",
"пр-кт",
"пр-т",
"пр",
"бульвар",
"б-р",
"бул",
"проезд",
"шоссе",
"ш",
"набережная",
"наб",
"площадь",
"пл",
"аллея",
"тупик",
"тракт",
"линия",
)
# Длинные типы раньше коротких, чтобы «улица» матчилось раньше «ул».
_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» → «белинского».
_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 Марта») сюда не попадает.
_DEDUP_HOUSE_NO_RE = re.compile(
r"\b(?:д(?:ом)?\.?\s*)?(\d+(?:[а-яё]|/\d+)?)\b",
flags=re.IGNORECASE,
)
# Слова-маркеры дома, которые могли попасть в захват названия (напр. «ленина д 5»).
_DEDUP_HOUSE_MARKER_WORDS = frozenset(
{"дом", "д", "корпус", "корп", "к", "строение", "стр", "литера", "лит", "владение", "вл"}
)
# Шум-токены (город/страна/район/микрорайон) на случай, если попали в название.
_DEDUP_ADDR_NOISE_WORDS = frozenset(
{
"россия",
"рф",
"екатеринбург",
"г",
"город",
"область",
"обл",
"район",
"р",
"мкр",
"м-н",
"микрорайон",
"квартал",
}
)
def _norm_addr_for_dedup(addr: str | None) -> str: def _parse_street_house(addr: str | None) -> tuple[str, str]:
"""Нормализация адреса для физического ключа: lower + схлопнутая пунктуация. """(street_token, house_no) из адреса произвольного source-формата (#2265).
Кросс-source адреса форматируются по-разному («ул. Ленина, д.5» vs street_token название улицы без типа (улица/пер/пр), города, района и
«Ленина 5») нормализуем к сравнимому виду. Пустой/None "". «Россия»; нумерованные улицы («8 Марта») сохраняют ведущую цифру. house_no
первое число ПОСЛЕ названия улицы (может отсутствовать: cian/domklik часто
без номера). Не распознано ("", "").
""" """
if not addr: if not addr:
return "" return "", ""
return _ADDR_NORM_RE.sub(" ", addr.strip().lower()).strip() s = addr.strip().lower()
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 _DEDUP_HOUSE_MARKER_WORDS and w not in _DEDUP_ADDR_NOISE_WORDS
]
street = " ".join(words).strip()
house = ""
hm = _DEDUP_HOUSE_NO_RE.search(s, m.end())
if hm:
house = hm.group(1)
return street, house
def _phys_dedup_key(lot: dict[str, Any]) -> tuple[str, Any, int, int] | None: def _extract_street_token(addr: str | None) -> str:
"""Физический ключ лота (building, floor, area_bucket, price_bucket). """Нормализованный уличный токен для дедуп-ключа (#2265). См. _parse_street_house."""
return _parse_street_house(addr)[0]
None, если нет данных для ключа (нет площади/цены/building) такой лот
НЕ дедупим (оставляем как уникальный, чтобы не сливать неоднозначное). def _lot_dedup_components(
building = building_cadastral_number (надёжнее) ИЛИ нормализованный address. lot: dict[str, Any],
area_bucket = round(area_m2) (~±0.5 м² поглощает округление 66.9 vs 67.0). ) -> tuple[str, str, tuple[str, Any, int, int] | None, tuple[str, Any, int, int] | None]:
price_bucket = round(price_rub / _DEDUP_PRICE_BUCKET_RUB): у настоящего """(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)
(у настоящего кросс-поста цена идентична, допуск лишь гасит округление).
house_no extra-сигнал для street-union guard (не входит в equality-ключ).
Нет площади/цены композиты None (лот уникален, не дедупится).
""" """
area = lot.get("area_m2") area = lot.get("area_m2")
price = lot.get("price_rub") price = lot.get("price_rub")
if not area or not price: 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") cad = lot.get("building_cadastral_number")
building = ( cad_s = str(cad).strip() if cad and str(cad).strip() else ""
str(cad).strip() if cad and str(cad).strip() else _norm_addr_for_dedup(lot.get("address")) cad_key = (cad_s, floor, area_b, price_b) if cad_s else None
) street, house = _parse_street_house(lot.get("address"))
if not building: street_key = (street, floor, area_b, price_b) if street else None
return None return cad_s, house, cad_key, street_key
return (
building,
lot.get("floor"), def _phys_dedup_key(lot: dict[str, Any]) -> tuple[str, Any, int, int] | None:
round(float(area)), """Первичный физический ключ (building, floor, area_bucket, price_bucket).
round(float(price) / _DEDUP_PRICE_BUCKET_RUB),
) building = cadnum (надёжнее) ИЛИ street_token (#2265). None, если нет
площади/цены или не из чего построить building. Сохраняет 4-кортежную форму
(canonical-ключ; union-find в _dedup_cross_source использует оба композита).
"""
_cad_s, _house, 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]: def _dedup_rep_key(lot: dict[str, Any]) -> tuple[float, int, str, str]:
@ -4599,36 +4742,99 @@ 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]]: def _dedup_cross_source(lots: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Схлопывает кросс-source дубли одного физического лота (#2087 H4). """Схлопывает кросс-source дубли одного физического лота (#2087 H4 / #2265).
Из группы с общим физическим ключом (_phys_dedup_key) оставляет ОДНОГО Union-find по физическим композитам: два лота дубли, если совпал ЛЮБОЙ
представителя (свежайший scraped_at), НЕ суммирует. Порядок вывода по ключ cadnum-композит ИЛИ street-композит (_lot_dedup_components). Из группы
первому появлению группы (сохраняет relevance-сортировку из _fetch_analogs оставляет ОДНОГО представителя (свежайший scraped_at), НЕ суммирует. Порядок
для top-N UI). Представитель несёт свой source n_analogs/median/cv/ вывода по первому появлению группы (сохраняет relevance-сортировку из
source_counts/sources_used считаются по физическим лотам («лот считается _fetch_analogs для top-N UI). Представитель несёт свой source n_analogs/
один раз»; source кросс-поста, не ставший представителем, выпадает из median/cv/source_counts/sources_used считаются по физическим лотам («лот
выборки по нему нет независимой ценовой точки). Флаг OFF no-op считается один раз»; source кросс-поста, не ставший представителем, выпадает
(байт-идентичный проход). Лоты без ключа (нет площади/цены/building) не из выборки по нему нет независимой ценовой точки).
дедупятся остаются уникальными.
#2265: cadnum-композит обрабатывается первым (авторитетный), затем street-
композит но union по street НЕ сливает два РАЗНЫХ непустых кадастра
(разные здания) И два РАЗНЫХ извлечённых номера дома (equal-or-missing =
сливаем, both-present-different = нет). Оба guard'а — на уровне корня группы,
устойчивы к порядку. Флаг OFF no-op (байт-идентичный проход). Лоты без
площади/цены не дедупятся остаются уникальными.
""" """
if not settings.estimate_dedup_analogs_enabled or len(lots) < 2: if not settings.estimate_dedup_analogs_enabled or len(lots) < 2:
return lots return lots
groups: dict[Any, list[dict[str, Any]]] = {} n = len(lots)
order: list[Any] = [] parent = list(range(n))
for lot in lots: comps = [_lot_dedup_components(lot) for lot in lots]
key: Any = _phys_dedup_key(lot) # cadnum/house группы (root → «» если группа без/со смешанным значением).
if key is None: root_cad = [c[0] for c in comps]
key = ("__nodup__", id(lot)) # неоднозначный → уникальный псевдоключ root_house = [c[1] for c in comps]
if key not in groups:
groups[key] = [] def find(x: int) -> int:
order.append(key) while parent[x] != x:
groups[key].append(lot) parent[x] = parent[parent[x]] # path-halving
x = parent[x]
return x
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 # разные кадастры → разные здания, не сливаем
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_cad
root_house[ra] = merged_house
else:
parent[ra] = rb
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, _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, guard_house=False)
# 2) street-композит — union при отсутствии cadnum- И house-конфликта.
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
prev = street_owner.get(street_key)
if prev is None:
street_owner[street_key] = i
else:
union(prev, i, guard_house=True)
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]] = [] result: list[dict[str, Any]] = []
collapsed = 0 collapsed = 0
for key in order: for root in order:
group = groups[key] group = groups[root]
if len(group) == 1: if len(group) == 1:
result.append(group[0]) result.append(group[0])
else: else:
@ -4636,7 +4842,7 @@ def _dedup_cross_source(lots: list[dict[str, Any]]) -> list[dict[str, Any]]:
collapsed += len(group) - 1 collapsed += len(group) - 1
if collapsed: if collapsed:
logger.info( 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(lots),
len(result), len(result),
collapsed, collapsed,

View file

@ -12,6 +12,8 @@ No DB: fake-Session (monkeypatched .execute/.rollback). DATABASE_URL нужен
""" """
import os import os
from collections.abc import Iterator
from contextlib import contextmanager
from types import SimpleNamespace from types import SimpleNamespace
from typing import Any from typing import Any
@ -37,6 +39,11 @@ class _OkDB:
self._row = SimpleNamespace(ratio=ratio, basis=basis) self._row = SimpleNamespace(ratio=ratio, basis=basis)
self.rolled_back = False 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: def execute(self, *_a: Any, **_k: Any) -> _Result:
return _Result(self._row) return _Result(self._row)
@ -50,6 +57,10 @@ class _BoomDB:
def __init__(self) -> None: def __init__(self) -> None:
self.rolled_back = False self.rolled_back = False
@contextmanager
def begin_nested(self) -> Iterator[Any]:
yield SimpleNamespace()
def execute(self, *_a: Any, **_k: Any) -> _Result: def execute(self, *_a: Any, **_k: Any) -> _Result:
raise RuntimeError("InFailedSqlTransaction") raise RuntimeError("InFailedSqlTransaction")

View file

@ -179,3 +179,223 @@ def test_dedup_lots_without_key_stay_unique(monkeypatch: pytest.MonkeyPatch) ->
] ]
out = estimator._dedup_cross_source(lots) out = estimator._dedup_cross_source(lots)
assert len(out) == 2 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
# --------------------------------------------------------------------------- #
# #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

View file

@ -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"