fix(tradein/geocoder): фолбэк на локальные houses — часть адресов ЕКБ давала гарантированный ноль аналогов #2891
6 changed files with 915 additions and 11 deletions
|
|
@ -223,6 +223,13 @@ class AggregatedEstimate(BaseModel):
|
|||
# UI (снизить доверие / переспросить город), НЕ персистится в БД
|
||||
# (ephemeral, только для текущего POST /estimate ответа).
|
||||
target_city_ambiguous: bool = False
|
||||
# #2626: True если координаты дал ПОСЛЕДНИЙ тир geocode() — fallback на `houses`
|
||||
# (см. `app.services.geocoder._local_houses_match`), а не Nominatim/geoportal/
|
||||
# cadastral. Значит адрес пользователя не совпал буквально (разговорное/усечённое
|
||||
# имя улицы или отсутствующий корпус), но был однозначно сопоставлен с домом из
|
||||
# скрейпленных листингов. Честный сигнал для UI («адрес уточнён автоматически»),
|
||||
# НЕ персистится в БД (ephemeral, как и `target_city_ambiguous`).
|
||||
target_address_refined: bool = False
|
||||
sources_used: list[str] = Field(default_factory=list) # ['avito', 'cian', 'rosreestr']
|
||||
data_freshness_minutes: int | None = None # сколько минут назад был самый свежий парсинг
|
||||
# абсолютный timestamp самого свежего парсинга аналогов
|
||||
|
|
|
|||
|
|
@ -37,6 +37,12 @@ DADATA_SUGGEST_URL = "https://suggestions.dadata.ru/suggestions/api/4_1/rs/sugge
|
|||
_DADATA_TIMEOUT_S = 8.0
|
||||
_DADATA_SUGGEST_TIMEOUT_S = 5.0
|
||||
|
||||
# Троттлинг WARNING «услуга CLEAN выключена на аккаунте» (#dadata-403-noise) —
|
||||
# статичная конфигурация аккаунта, не транзиентный сбой. Первый раз за процесс
|
||||
# логируется на WARNING, дальше — DEBUG, чтобы не заливать логи одним и тем же
|
||||
# сообщением на каждый /estimate (было: logger.error на каждый запрос).
|
||||
_clean_disabled_warned = False
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DadataAddressResult:
|
||||
|
|
@ -172,15 +178,29 @@ async def clean_address(address: str) -> DadataAddressResult | None:
|
|||
# но услуга «Стандартизация» (CLEAN) не подключена на аккаунте. Refresh токена НЕ
|
||||
# поможет — нужно включить услугу в кабинете DaData ИЛИ полагаться на suggest-fallback
|
||||
# (enrich_address). Разделяем сообщения, чтобы не гонять зря за ротацией токена.
|
||||
#
|
||||
# Это НЕ сбой (аккаунт постоянно живёт с выключенной услугой, enrich_address уже
|
||||
# graceful-деградирует на suggest — см. ниже) — раньше это било logger.error на
|
||||
# КАЖДЫЙ пользовательский запрос (164 события/запрос-волна в проде), из-за чего
|
||||
# ERROR переставал значить «настоящий сбой». WARNING один раз за процесс (дальше —
|
||||
# DEBUG) сохраняет видимость причины без шума на каждый /estimate.
|
||||
if status == 403 and (
|
||||
"disabled" in body_preview.lower() or "feature" in body_preview.lower()
|
||||
):
|
||||
logger.error(
|
||||
"dadata: HTTP 403 — услуга CLEAN (Стандартизация) выключена на аккаунте "
|
||||
"(токен валиден, НЕ отклонён). Включи услугу в кабинете DaData или "
|
||||
"полагайся на suggest-fallback (enrich_address). Ответ: %r",
|
||||
body_preview,
|
||||
)
|
||||
global _clean_disabled_warned
|
||||
if not _clean_disabled_warned:
|
||||
logger.warning(
|
||||
"dadata: HTTP 403 — услуга CLEAN (Стандартизация) выключена на аккаунте "
|
||||
"(токен валиден, НЕ отклонён). Включи услугу в кабинете DaData или "
|
||||
"полагайся на suggest-fallback (enrich_address). Ответ: %r "
|
||||
"(повторы этого сообщения в рамках процесса логируются на DEBUG)",
|
||||
body_preview,
|
||||
)
|
||||
_clean_disabled_warned = True
|
||||
else:
|
||||
logger.debug(
|
||||
"dadata: HTTP 403 CLEAN disabled (уже предупреждено WARNING в этом процессе)"
|
||||
)
|
||||
else:
|
||||
logger.error(
|
||||
"dadata: HTTP %d — auth/secret rejected. "
|
||||
|
|
|
|||
|
|
@ -4762,6 +4762,7 @@ async def estimate_quality(
|
|||
target_lat=geo.lat,
|
||||
target_lon=geo.lon,
|
||||
target_city_ambiguous=geo.city_ambiguous,
|
||||
target_address_refined=geo.address_refined,
|
||||
sources_used=sources_used,
|
||||
data_freshness_minutes=freshness_min,
|
||||
last_scraped_at=last_scraped_at,
|
||||
|
|
|
|||
|
|
@ -44,6 +44,22 @@ class GeocodeResult:
|
|||
# результата — честный сигнал «доверяй, но проверяй», чтобы вызывающий код мог
|
||||
# понизить confidence / переспросить город у пользователя. См. `_resolve_city_for_geocode`.
|
||||
city_ambiguous: bool = False
|
||||
# #2626: True если результат дал ПОСЛЕДНИЙ локальный тир — fallback на `houses`
|
||||
# (скрейпленные листинги, см. `_local_houses_match`) — а не Nominatim/geoportal/
|
||||
# cadastral. Срабатывает, когда в тексте адреса опечатка/сокращение улицы
|
||||
# («Онуфриева» вместо канонического «Начдива Онуфриева» в ГАР) или отсутствует
|
||||
# корпус («49» вместо реального «49к1») — houses-фолбэк нашёл ОДНОЗНАЧНЫЙ дом по
|
||||
# нормализованному совпадению. Честный сигнал вызывающему коду «адрес уточнён
|
||||
# автоматически», НЕ эвристика на корректность — см. `geocode()`/`_local_houses_match`.
|
||||
# Houses-фолбэк НЕ пишет свой результат в `geocode_cache` (менее надёжный
|
||||
# источник координат, чем geoportal/cadastral/Nominatim — #2626 review R2 #4),
|
||||
# поэтому этот сигнал переживает КАЖДЫЙ повторный запрос того же сырого
|
||||
# адреса. `geocode_cache` вообще не хранит этот флаг (схему не трогаем) —
|
||||
# если бы houses-хит когда-нибудь попал в кэш, на cache-hit `address_refined`
|
||||
# вернулся бы `False` (та же судьба у `city_ambiguous` при cache-hit — см.
|
||||
# `_geocode_resolve`, восстанавливается `replace()` из текущего вызова, а не
|
||||
# из кэша).
|
||||
address_refined: bool = False
|
||||
|
||||
|
||||
# ── EKB bounding boxes ───────────────────────────────────────────────────────
|
||||
|
|
@ -1188,12 +1204,13 @@ def _cadastral_house_match(db: Session, street: str, house: str) -> GeocodeSugge
|
|||
|
||||
ВНИМАНИЕ, цепочки различаются — не путать:
|
||||
* `geocode()` : geoportal → cadastral → `_cadastral_forward_sync`
|
||||
→ Nominatim → None. Тира DaData тут НЕТ.
|
||||
→ Nominatim → `_local_houses_match` (#2626, houses-фолбэк)
|
||||
→ None. Тира DaData тут НЕТ.
|
||||
* `suggest()` : cadastral → DaData → Nominatim (единственный вызов
|
||||
`_dadata_suggest`).
|
||||
То есть на прямом вызове `geocode()` (API/PDF/восстановление по `?id=`)
|
||||
адрес с литерой, неизвестный ни геопорталу, ни Nominatim, даёт None —
|
||||
оценка не строится. Это сознательный выбор: честный отказ вместо
|
||||
адрес с литерой, неизвестный ни геопорталу, ни Nominatim, ни houses-фолбэку,
|
||||
даёт None — оценка не строится. Это сознательный выбор: честный отказ вместо
|
||||
уверенно-неверной оценки чужого дома. Основной UI-путь этим не задет —
|
||||
координаты приходят из выбранной подсказки (`ParamsPanel.tsx:776` →
|
||||
`api/v1/trade_in.py:128` использует lat/lon напрямую, минуя `geocode()`).
|
||||
|
|
@ -1310,6 +1327,283 @@ def _geoportal_house_match(db: Session, street: str, house: str) -> GeocodeSugge
|
|||
)
|
||||
|
||||
|
||||
# ── Local `houses` fallback (#2626) — последний тир geocode() ───────────────
|
||||
# Мотивация: 28/1084 прод-оценок с lat IS NULL — гарантированный ноль аналогов,
|
||||
# клиент не получает оценку вовсе. Живые примеры (адрес пользователя → ГАР/houses):
|
||||
# «ул Крестинского, д 49» — «49» голого нет в houses, есть только «49к1»
|
||||
# (корпус потерян при вводе, houses id 9980 «улица Крестинского, 49к1»);
|
||||
# «ул Онуфриева, д 24» — houses называет улицу «Начдива Онуфриева» (ГАР),
|
||||
# пользователь пишет только последнее слово имени.
|
||||
# Дом уже ЕСТЬ в `houses` (скрейпленные листинги avito/cian/derived/yandex) с
|
||||
# координатами — Nominatim и ЕКБ-реестры (geoportal/cad_buildings) эти формы не
|
||||
# резолвят, а houses чаще содержит именно то написание, которым реально пользуются
|
||||
# люди (агрегировано из объявлений, а не из официального ГАР).
|
||||
#
|
||||
# Номер дома в `houses.address` — СВОБОДНЫЙ текст источников (avito/cian/derived/
|
||||
# yandex_valuation): «улица X, 49к1» / «X ул.,88/2» / «X, 44» — БЕЗ единого формата
|
||||
# и без «д./дом»-маркера, в отличие от `gendesign_cad_buildings.readable_address`.
|
||||
# Поэтому здесь — собственная, более широкая нормализация номера (со слэшем
|
||||
# «88/2» и корпусом «49к1»), а НЕ переиспользование `_HOUSE_NUM`/`_norm_house`
|
||||
# (те заточены под geoportal/cad_buildings реестры, где «/N» и «корпус N» реже).
|
||||
_LOCAL_HOUSE_TOKEN_RE = re.compile(
|
||||
r"(\d+(?:\s*/\s*\d+)?(?:\s*-?\s*(?:к|корп\.?|корпус)\.?\s*-?\s*\d+)?(?:\s*-?\s*[а-яё])?)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _norm_local_house(raw: str) -> str:
|
||||
"""Канон номера дома для houses-фолбэка.
|
||||
|
||||
«49 к 1» / «49-к1» / «49 корпус 1» → «49к1»; «88 / 2» → «88/2»; «35А» → «35а».
|
||||
"""
|
||||
s = raw.strip().lower()
|
||||
s = re.sub(r"\s+", "", s)
|
||||
s = re.sub(r"корпус|корп\.?", "к", s)
|
||||
s = re.sub(r"-(к\d+)", r"\1", s)
|
||||
s = re.sub(r"-([а-яё])$", r"\1", s)
|
||||
return s
|
||||
|
||||
|
||||
# Хвостовой мусор ПОСЛЕ номера дома — квартира/офис/помещение/подъезд/этаж.
|
||||
# НЕ включает «корп/корпус/к» (в отличие от `_RE_APT_TAIL` выше) — корпус тут
|
||||
# ЧАСТЬ номера дома, который должен остаться видимым для `_LOCAL_HOUSE_TOKEN_RE`
|
||||
# («49к1», «26 к 1» — корпус нельзя терять). Без этой зачистки
|
||||
# `_extract_local_house_token` (берёт ПОСЛЕДНЕЕ число в строке) находит номер
|
||||
# квартиры/этажа вместо дома — прод-баг #2626 review R2 #1: «...Педагогическая,
|
||||
# д 15, кв 11» отдавал дом «11» (координаты ЧУЖОГО здания) вместо «15».
|
||||
_RE_LOCAL_APT_TAIL = re.compile(
|
||||
r"[,\s]\s*(?:кв|квартира|оф|офис|пом|помещение|лит|подъезд|этаж)\.?\s*\d.*$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _extract_local_house_token(address: str) -> str | None:
|
||||
"""Номер дома из ПОЛЬЗОВАТЕЛЬСКОГО адреса — с учётом «/N» и «корпус N» хвостов,
|
||||
которые `_parse_street_house`/`_HOUSE_NUM` обрезают (см. коммент у
|
||||
`_LOCAL_HOUSE_TOKEN_RE`). Берём ПОСЛЕДНЕЕ совпадение — номер дома в русском
|
||||
адресе почти всегда в хвосте строки. None, если цифр нет вовсе.
|
||||
|
||||
Квартирный/этажный/подъездный хвост зачищается ДО поиска номера
|
||||
(`_RE_LOCAL_APT_TAIL`) — иначе «последнее число в строке» это номер
|
||||
квартиры/этажа, а не дома (см. докстринг у `_RE_LOCAL_APT_TAIL`).
|
||||
"""
|
||||
s = _RE_POSTAL.sub(" ", " ".join(address.lower().strip().split())).strip(" ,.")
|
||||
if not s:
|
||||
return None
|
||||
s = _RE_LOCAL_APT_TAIL.sub(" ", s).strip(" ,.")
|
||||
if not s:
|
||||
return None
|
||||
matches = list(_LOCAL_HOUSE_TOKEN_RE.finditer(s))
|
||||
if not matches:
|
||||
return None
|
||||
return _norm_local_house(matches[-1].group(1))
|
||||
|
||||
|
||||
# Маркеры района/города/страны — обрезаются из `houses.address` перед сравнением
|
||||
# улицы (`_clean_local_house_street`). Хвостовое сравнение (см. ниже) и без этого
|
||||
# устойчиво к ЛИШНЕМУ префиксу («р-н Ленинский, мкр. Юго-Западный, улица X» всё
|
||||
# равно оканчивается на «... улица x» и матчит суффиксом), но тип улицы ПОСЛЕ
|
||||
# имени («Хрустальногорская ул.») ломает суффикс без явной зачистки типа.
|
||||
# Хвостовой якорь — lookahead на пробел/конец строки, а НЕ `\b`: «ул.» в самом
|
||||
# конце сегмента (частая форма в houses.address) заканчивается точкой, а `\b`
|
||||
# сразу после точки на границе строки не срабатывает (оба «символа» не-\w) —
|
||||
# тип-слово матчилось бы БЕЗ точки, точка оставалась бы висеть («хрустальногорская .»)
|
||||
# и ломала «хвостовое» сравнение улицы (реальный прод-кейс: id 13080 houses).
|
||||
_LOCAL_HOUSE_STREET_TYPE_RE = re.compile(rf"\b(?:{_STREET_TYPE})\.?(?=\s|$)", re.IGNORECASE)
|
||||
|
||||
|
||||
def _clean_local_house_street(segment: str) -> str:
|
||||
"""«Хрустальногорская ул.» / «улица Начдива Онуфриева» → «хрустальногорская» /
|
||||
«начдива онуфриева»: lower, без типа улицы, схлопнутые пробелы.
|
||||
|
||||
Общая нормализация и для запроса пользователя (уже typeless из
|
||||
`_parse_street_house`, но повторный проход — no-op), и для `houses.address`.
|
||||
"""
|
||||
s = _LOCAL_HOUSE_STREET_TYPE_RE.sub(" ", segment.lower())
|
||||
return " ".join(s.split())
|
||||
|
||||
|
||||
def _row_local_house(address: str) -> tuple[str, str] | None:
|
||||
"""Разбирает ОДНУ строку `houses.address` на (street_clean, house_norm).
|
||||
|
||||
Номер дома — ПОСЛЕДНИЙ через-запятую сегмент (во всех живых формах: «X, 49к1»,
|
||||
«X ул.,88/2», «X, 44»), СОВПАДЕНИЕ С НАЧАЛА этого сегмента (не всей строки) —
|
||||
покрывает и «49к1» целиком, и «35к1 · р-н Академический» (хвостовой мусор
|
||||
после номера отбрасывается). Известный неполный случай (не встретился в
|
||||
выборке): номер дома БЕЗ запятой перед ним — вернёт None, строка просто не
|
||||
станет кандидатом (не ложный матч).
|
||||
"""
|
||||
segments = [s.strip() for s in address.split(",") if s.strip()]
|
||||
if len(segments) < 2:
|
||||
return None
|
||||
m = _LOCAL_HOUSE_TOKEN_RE.match(segments[-1])
|
||||
if not m:
|
||||
return None
|
||||
house_norm = _norm_local_house(m.group(1))
|
||||
street_norm = _clean_local_house_street(" ".join(segments[:-1]))
|
||||
if not street_norm or not house_norm:
|
||||
return None
|
||||
return street_norm, house_norm
|
||||
|
||||
|
||||
def _street_tail_matches(row_street_norm: str, query_street_norm: str) -> bool:
|
||||
"""True если `query_street_norm` — «хвост» (последнее слово/слова) имени улицы
|
||||
в `houses` — «онуфриева» находит «начдива онуфриева» (ГАР-каноничное имя),
|
||||
регистронезависимо. Точное равенство тоже проходит (частый случай — короткие
|
||||
однословные улицы, «Малышева» == «Малышева»)."""
|
||||
return row_street_norm == query_street_norm or row_street_norm.endswith(" " + query_street_norm)
|
||||
|
||||
|
||||
# «24к1» → «24» (базовый номер варианта с корпусом/слэшем); «44» (голый номер,
|
||||
# без суффикса) → None. Используется ТОЛЬКО для sibling-guard (см. ниже) —
|
||||
# отличить «этот дом однозначно к1» от «этого дома несколько корпусов, а у
|
||||
# нас в вводе просто нет данных, какой именно».
|
||||
_LOCAL_HOUSE_VARIANT_BASE_RE = re.compile(r"^(\d+)(?:к\d+|/\d+)$")
|
||||
|
||||
|
||||
def _local_houses_match(db: Session, street: str, house: str) -> GeocodeSuggestion | None:
|
||||
"""Последний локальный тир `geocode()` (#2626) — fallback на `houses`
|
||||
(скрейпленные листинги avito/cian/derived/yandex, own DB table, БЕЗ FDW).
|
||||
|
||||
Вызывается ТОЛЬКО когда geoportal/cadastral/Nominatim уже не дали результата.
|
||||
Допущения, все defensive (при неоднозначности — None, не гадаем):
|
||||
|
||||
1. Улица матчится «по хвосту» (`_street_tail_matches`) — ловит расхождение
|
||||
разговорного/сокращённого имени («Онуфриева») и канонического ГАР-имени в
|
||||
houses («Начдива Онуфриева»).
|
||||
2. Координаты строки-кандидата обязаны лежать в широком ЕКБ-bbox
|
||||
(`is_within_ekb_bbox_wide`) — `houses` НЕ ЕКБ-only реестр (в отличие от
|
||||
geoportal/cad_buildings): 21% строк с координатами лежат вне области ЕКБ,
|
||||
местами вплоть до другого региона (#2626 review R2 #2 — прод-пример
|
||||
«улица Маяковского, 7» в houses это Серов, а не запрошенный
|
||||
Екатеринбург). `use_local_ekb` в `geocode()` гейтит только ЗАПРОС
|
||||
пользователя, не страхует от грязной строки-источника.
|
||||
3. Номер дома — сперва точное совпадение; нет — пробуем `<номер>к1` (частый
|
||||
случай: пользователь ввёл «49», у дома есть только корпус «49к1»), но
|
||||
ТОЛЬКО если среди кандидатов улицы НЕТ других корпусов/дробей этого же
|
||||
номера («24к2», «24/2» и т.п.) — иначе «к1» такая же угадайка, как и
|
||||
любой другой корпус, и реальные дома могут быть в 250-400м друг от друга
|
||||
(#2626 review R2 #3, прод-пример «Начдива Онуфриева, 24»: 24к1/24к2/24к3
|
||||
— три разных здания).
|
||||
4. ЛЮБОЙ шаг, где кандидатов больше одного (после дедупа по округлённым
|
||||
координатам — разные source-строки ОДНОГО дома не в счёт), возвращает
|
||||
None — угадывать нельзя.
|
||||
|
||||
SQL — дешёвый ILIKE-префильтр по последнему слову улицы (нет индекса на
|
||||
`houses.address`, но тир последний и редкий — не на каждый запрос) с
|
||||
детерминированным ORDER BY (дедуп по координатам иначе непредсказуемо
|
||||
выбирал бы, какая из двух ~идентичных source-строк станет ответом —
|
||||
#2626 review R2 #5); вся точная логика (суффикс улицы, bbox, равенство
|
||||
номера) — в Python, что и делает её юнит-тестируемой без реальной БД
|
||||
(см. `test_geocoder_local_houses_fallback.py`).
|
||||
|
||||
Результат этого тира НЕ кэшируется в `geocode_cache` вызывающей стороной
|
||||
(см. `geocode()`) — `houses`-координаты из скрейпленных объявлений менее
|
||||
надёжны, чем geoportal/cadastral/Nominatim, а сам lookup дешёвый и локальный
|
||||
(#2626 review R2 #4).
|
||||
"""
|
||||
query_street_norm = _clean_local_house_street(street)
|
||||
if not query_street_norm:
|
||||
return None
|
||||
query_house_norm = _norm_local_house(house)
|
||||
if not query_house_norm:
|
||||
return None
|
||||
last_word = query_street_norm.split()[-1]
|
||||
|
||||
try:
|
||||
rows = db.execute(
|
||||
text("""
|
||||
SELECT address, lat, lon
|
||||
FROM houses
|
||||
WHERE address ILIKE CAST('%' || :w || '%' AS text)
|
||||
AND lat IS NOT NULL AND lon IS NOT NULL
|
||||
ORDER BY address, id
|
||||
"""),
|
||||
{"w": last_word},
|
||||
).fetchall()
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"local houses fallback query failed for street=%r house=%r",
|
||||
street,
|
||||
house,
|
||||
exc_info=True,
|
||||
)
|
||||
return None
|
||||
|
||||
# Street-tail + bbox фильтр — один проход, дальше переиспользуется и для
|
||||
# точного совпадения, и для corpus-1 догадки, и для sibling-guard.
|
||||
street_rows: list[tuple[str, float, float, str]] = [] # (house_norm, lat, lon, addr)
|
||||
for r in rows:
|
||||
parsed = _row_local_house(str(r.address or ""))
|
||||
if parsed is None:
|
||||
continue
|
||||
row_street_norm, row_house_norm = parsed
|
||||
if not _street_tail_matches(row_street_norm, query_street_norm):
|
||||
continue
|
||||
lat, lon = float(r.lat), float(r.lon)
|
||||
if not is_within_ekb_bbox_wide(lat, lon):
|
||||
continue
|
||||
street_rows.append((row_house_norm, lat, lon, str(r.address)))
|
||||
|
||||
def _candidates(house_norm: str) -> list[tuple[str, float, float]]:
|
||||
out: list[tuple[str, float, float]] = []
|
||||
seen_coords: set[tuple[float, float]] = set()
|
||||
for row_house_norm, lat, lon, addr in street_rows:
|
||||
if row_house_norm != house_norm:
|
||||
continue
|
||||
coord_key = (round(lat, 4), round(lon, 4)) # ~11m — дедуп источников
|
||||
if coord_key in seen_coords:
|
||||
continue
|
||||
seen_coords.add(coord_key)
|
||||
out.append((addr, lat, lon))
|
||||
return out
|
||||
|
||||
exact = _candidates(query_house_norm)
|
||||
if len(exact) == 1:
|
||||
addr, lat, lon = exact[0]
|
||||
return GeocodeSuggestion(label=addr, full_address=addr, lat=lat, lon=lon, kind="house")
|
||||
if len(exact) > 1:
|
||||
logger.info(
|
||||
"local houses fallback: %d неоднозначных кандидата для %r %r — skip",
|
||||
len(exact),
|
||||
street,
|
||||
house,
|
||||
)
|
||||
return None
|
||||
|
||||
# Точного номера нет — пробуем «<номер>к1» (корпус потерян при вводе), ТОЛЬКО
|
||||
# если запрошенный номер — голое число (не пытаемся достраивать «49/2» → «49/2к1»).
|
||||
if query_house_norm.isdigit():
|
||||
corpus1 = f"{query_house_norm}к1"
|
||||
siblings = {
|
||||
row_house_norm
|
||||
for row_house_norm, _lat, _lon, _addr in street_rows
|
||||
if row_house_norm != corpus1
|
||||
and (m := _LOCAL_HOUSE_VARIANT_BASE_RE.match(row_house_norm)) is not None
|
||||
and m.group(1) == query_house_norm
|
||||
}
|
||||
if siblings:
|
||||
logger.info(
|
||||
"local houses fallback: корпус-1 %r неоднозначен — есть другие "
|
||||
"корпуса/дроби %s — skip",
|
||||
corpus1,
|
||||
sorted(siblings),
|
||||
)
|
||||
return None
|
||||
guessed = _candidates(corpus1)
|
||||
if len(guessed) == 1:
|
||||
addr, lat, lon = guessed[0]
|
||||
logger.info("local houses fallback: %r → корпус-1 %r (%s)", house, corpus1, addr)
|
||||
return GeocodeSuggestion(label=addr, full_address=addr, lat=lat, lon=lon, kind="house")
|
||||
if len(guessed) > 1:
|
||||
logger.info(
|
||||
"local houses fallback: корпус-1 %r неоднозначен (%d кандидата) — skip",
|
||||
corpus1,
|
||||
len(guessed),
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _cadastral_reverse_sync(db: Session, lat: float, lon: float, radius_m: int = 200) -> str | None:
|
||||
"""Reverse lookup via gendesign_cad_buildings FDW.
|
||||
|
||||
|
|
@ -1586,6 +1880,45 @@ async def _geocode_resolve(
|
|||
except Exception:
|
||||
logger.exception("nominatim geocoder failed")
|
||||
|
||||
# 4. Local `houses` fallback (#2626) — САМЫЙ ПОСЛЕДНИЙ тир, до возврата None.
|
||||
# 28/1084 прод-оценок имели lat IS NULL (гарантированный ноль аналогов) — дом
|
||||
# был в `houses` (скрейпленные листинги), но не в geoportal/cad_buildings и не
|
||||
# резолвился Nominatim'ом (разговорное/усечённое имя улицы или отсутствующий
|
||||
# в вводе корпус). См. `_local_houses_match`. EKB-only гейт — тот же, что у
|
||||
# geoportal/cadastral (houses — преимущественно ЕКБ-трафик, тот же риск
|
||||
# коллизии улица+дом с другим городом региона, что и мотивировал #2582);
|
||||
# координаты строки-кандидата ДОПОЛНИТЕЛЬНО проверяются bbox-ом внутри
|
||||
# `_local_houses_match` (гейт здесь фильтрует только запрос пользователя,
|
||||
# не грязь в самой таблице — #2626 review R2 #2).
|
||||
if use_local_ekb and parsed is not None:
|
||||
local_street, _parsed_house = parsed
|
||||
local_house = _extract_local_house_token(address) or _parsed_house
|
||||
hit = await asyncio.to_thread(_local_houses_match, db, local_street, local_house)
|
||||
if hit is not None:
|
||||
result = GeocodeResult(
|
||||
lat=hit.lat,
|
||||
lon=hit.lon,
|
||||
full_address=hit.full_address,
|
||||
provider="cache", # локальный DB-lookup, без внешнего HTTP — как geoportal
|
||||
confidence="exact",
|
||||
city_ambiguous=city_ambiguous,
|
||||
address_refined=True,
|
||||
)
|
||||
# НЕ кэшируем: houses-координаты (скрейпленные листинги) менее
|
||||
# надёжны, чем geoportal/cadastral/Nominatim, а сам lookup дешёвый
|
||||
# и локальный — кэш только продлевал бы жизнь возможной ошибке
|
||||
# источника (#2626 review R2 #4). Побочный эффект: `address_refined`
|
||||
# переживает КАЖДЫЙ повторный запрос этого сырого адреса, а не
|
||||
# только первый (было известным пределом до этого фикса).
|
||||
logger.info(
|
||||
"geocode local houses fallback: %s → (%.5f, %.5f) [%s]",
|
||||
addr_norm,
|
||||
result.lat,
|
||||
result.lon,
|
||||
hit.full_address,
|
||||
)
|
||||
return result
|
||||
|
||||
return None
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -781,12 +781,19 @@ def _mock_enrich_transport(
|
|||
|
||||
|
||||
async def test_clean_address_logs_feature_disabled_distinctly(caplog) -> None:
|
||||
"""403 «Feature CLEAN disabled» → None + сообщение про выключенную услугу (не про токен)."""
|
||||
"""403 «Feature CLEAN disabled» → None + сообщение про выключенную услугу (не про токен).
|
||||
|
||||
#dadata-403-noise: это статичная конфигурация аккаунта (не транзиентный сбой) —
|
||||
логируется на WARNING (не ERROR), чтобы ERROR продолжал значить «настоящий сбой»
|
||||
(раньше — logger.error на КАЖДЫЙ пользовательский запрос, 164 события в проде).
|
||||
"""
|
||||
from app.services import dadata
|
||||
|
||||
dadata._clean_disabled_warned = False # изоляция от порядка тестов (module-level throttle)
|
||||
|
||||
transport = _mock_transport_returning(403, CLEAN_FEATURE_DISABLED_BODY)
|
||||
with _patch_settings(), _patch_async_client(transport):
|
||||
with caplog.at_level(_logging.ERROR, logger="app.services.dadata"):
|
||||
with caplog.at_level(_logging.WARNING, logger="app.services.dadata"):
|
||||
result = await dadata.clean_address("Екатеринбург, Малышева 4")
|
||||
|
||||
assert result is None
|
||||
|
|
@ -794,6 +801,32 @@ async def test_clean_address_logs_feature_disabled_distinctly(caplog) -> None:
|
|||
assert "Стандартизация" in text or "выключена" in text
|
||||
# Не должны обвинять токен при feature-disabled.
|
||||
assert "auth/secret rejected" not in text
|
||||
# НЕ ERROR — статичная причина, не сбой (#dadata-403-noise).
|
||||
assert not any(rec.levelno >= _logging.ERROR for rec in caplog.records)
|
||||
|
||||
|
||||
async def test_clean_address_throttles_repeated_feature_disabled_warning(caplog) -> None:
|
||||
"""Второй (и далее) 403 CLEAN-disabled за один процесс → DEBUG, не повторный WARNING.
|
||||
|
||||
#dadata-403-noise: без троттлинга WARNING на каждый /estimate так же шумит логи,
|
||||
как раньше шумел ERROR — цель фикса теряется наполовину.
|
||||
"""
|
||||
from app.services import dadata
|
||||
|
||||
dadata._clean_disabled_warned = False
|
||||
|
||||
transport = _mock_transport_returning(403, CLEAN_FEATURE_DISABLED_BODY)
|
||||
with _patch_settings(), _patch_async_client(transport):
|
||||
with caplog.at_level(_logging.DEBUG, logger="app.services.dadata"):
|
||||
first = await dadata.clean_address("Екатеринбург, Малышева 4")
|
||||
caplog.clear()
|
||||
second = await dadata.clean_address("Екатеринбург, Ленина 10")
|
||||
|
||||
assert first is None
|
||||
assert second is None
|
||||
# Второй вызов — НИ ОДНОГО WARNING/ERROR (только DEBUG или тише).
|
||||
assert not any(rec.levelno >= _logging.WARNING for rec in caplog.records)
|
||||
assert dadata._clean_disabled_warned is True
|
||||
|
||||
|
||||
async def test_clean_address_logs_real_auth_rejection_as_auth(caplog) -> None:
|
||||
|
|
|
|||
510
tradein-mvp/backend/tests/test_geocoder_local_houses_fallback.py
Normal file
510
tradein-mvp/backend/tests/test_geocoder_local_houses_fallback.py
Normal file
|
|
@ -0,0 +1,510 @@
|
|||
"""Unit tests for the `houses` fallback tier of `geocode()` (#2626).
|
||||
|
||||
Covers:
|
||||
- `_norm_local_house`: normalization of corpus/slash house-number forms
|
||||
(«49 к 1» / «49-к1» / «49 корпус 1» → «49к1»; «88 / 2» → «88/2»).
|
||||
- `_extract_local_house_token`: pulling the house-number token out of a raw
|
||||
user address, WITH the corpus/slash suffix that `_parse_street_house`'s
|
||||
`_HOUSE_NUM` drops.
|
||||
- `_clean_local_house_street` / `_row_local_house`: extracting a comparable
|
||||
(street, house) pair out of the free-text `houses.address` column (multiple
|
||||
scraper source formats — avito/cian/derived/yandex_valuation).
|
||||
- `_street_tail_matches`: «Онуфриева» finds «Начдива Онуфриева» (ГАР canonical
|
||||
name), regardless of leading district/city noise.
|
||||
- `_local_houses_match`: full tier with a mocked DB session —
|
||||
exact number match, corpus-1 fallback guess («49» → «49к1»), and the
|
||||
defensive "ambiguous → None" invariant (no guessing on >1 distinct match).
|
||||
- `geocode()` wiring: local-houses tier is the LAST step, only reached when
|
||||
cache/geoportal/cadastral/Nominatim all miss, and marks
|
||||
`GeocodeResult.address_refined=True`.
|
||||
|
||||
Real prod addresses (#2626, lat IS NULL in trade_in_estimates) are used as
|
||||
regression fixtures: «ул Онуфриева, д 24» → «Начдива Онуфриева, 24к1»,
|
||||
«ул. Хрустальногорская, д. 88/2» → exact match, «ул Крестинского, д 49» →
|
||||
genuinely ambiguous in prod data (two DIFFERENT buildings both stored as
|
||||
«Крестинского, 49к1» — must NOT resolve, per the defensive "no guessing" rule).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||
|
||||
_wp_mock = MagicMock()
|
||||
sys.modules.setdefault("weasyprint", _wp_mock)
|
||||
|
||||
from app.services.geocoder import ( # noqa: E402
|
||||
GeocodeSuggestion,
|
||||
_clean_local_house_street,
|
||||
_extract_local_house_token,
|
||||
_local_houses_match,
|
||||
_norm_local_house,
|
||||
_row_local_house,
|
||||
_street_tail_matches,
|
||||
geocode,
|
||||
)
|
||||
|
||||
# ── _norm_local_house ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("raw", "expected"),
|
||||
[
|
||||
("49 к 1", "49к1"),
|
||||
("49-к1", "49к1"),
|
||||
("49 корпус 1", "49к1"),
|
||||
("49 корп. 1", "49к1"),
|
||||
("88 / 2", "88/2"),
|
||||
("88/2", "88/2"),
|
||||
("35А", "35а"),
|
||||
("13Б", "13б"),
|
||||
("13-б", "13б"),
|
||||
("44", "44"),
|
||||
],
|
||||
)
|
||||
def test_norm_local_house(raw: str, expected: str) -> None:
|
||||
assert _norm_local_house(raw) == expected
|
||||
|
||||
|
||||
# ── _extract_local_house_token ───────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("address", "expected"),
|
||||
[
|
||||
("ул Крестинского, д 49", "49"),
|
||||
("ул. Хрустальногорская, д. 88/2", "88/2"),
|
||||
("ул Онуфриева, д 24", "24"),
|
||||
("Крестинского 49к1", "49к1"),
|
||||
("8 Марта 204", "204"), # digit-leading street name doesn't confuse it
|
||||
("Малышева 30", "30"),
|
||||
# #2626 review R2 #1 — прод-баг: квартира подменяла дом («д 15, кв 11»
|
||||
# → дом «11», чужое здание). Реальные строки из trade_in_estimates:
|
||||
(
|
||||
"620078, Свердловская обл, г Екатеринбург, Кировский р-н, "
|
||||
"ул Педагогическая, д 15, кв 11",
|
||||
"15",
|
||||
),
|
||||
(
|
||||
"620078, Свердловская обл, г Екатеринбург, Кировский р-н, "
|
||||
"ул Педагогическая, д 15, кв 48",
|
||||
"15",
|
||||
),
|
||||
# корпус ПЕРЕД квартирой — «26 к 1» обязан остаться частью номера дома,
|
||||
# «кв 41» — уйти:
|
||||
(
|
||||
"620149, Свердловская обл, г Екатеринбург, Ленинский р-н, "
|
||||
"ул Начдива Онуфриева, д 26 к 1, кв 41",
|
||||
"26к1",
|
||||
),
|
||||
# подъезд/этаж — тот же класс бага, что и квартира (последнее число в
|
||||
# строке — не дом):
|
||||
(
|
||||
"Россия, Свердловская область, Екатеринбург, Трамвайный переулок, "
|
||||
"2к2, подъезд 1, этаж 25, кв. 205",
|
||||
"2к2",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_extract_local_house_token(address: str, expected: str) -> None:
|
||||
assert _extract_local_house_token(address) == expected
|
||||
|
||||
|
||||
def test_extract_local_house_token_none_for_garbage() -> None:
|
||||
assert _extract_local_house_token("") is None
|
||||
assert _extract_local_house_token("Екатеринбург") is None
|
||||
|
||||
|
||||
# ── _clean_local_house_street / _street_tail_matches ────────────────────────
|
||||
|
||||
|
||||
def test_clean_local_house_street_strips_type_regardless_of_position() -> None:
|
||||
"""Тип улицы ДО имени («улица X») и ПОСЛЕ («X ул.») — оба зачищаются."""
|
||||
assert _clean_local_house_street("улица Начдива Онуфриева") == "начдива онуфриева"
|
||||
assert _clean_local_house_street("Хрустальногорская ул.") == "хрустальногорская"
|
||||
|
||||
|
||||
def test_street_tail_matches_onufrieva_finds_nachdiva_onufrieva() -> None:
|
||||
"""Ядро #2626: «Онуфриева» (как пишет пользователь) находит «Начдива
|
||||
Онуфриева» (каноничное имя ГАР, как в houses.address)."""
|
||||
assert _street_tail_matches("начдива онуфриева", "онуфриева") is True
|
||||
|
||||
|
||||
def test_street_tail_matches_exact_equality() -> None:
|
||||
assert _street_tail_matches("хрустальногорская", "хрустальногорская") is True
|
||||
|
||||
|
||||
def test_street_tail_matches_rejects_non_suffix_substring() -> None:
|
||||
"""«Онуфриева» НЕ находит несвязанную улицу, где она — не хвостовое слово."""
|
||||
assert _street_tail_matches("онуфриева южная", "онуфриева") is False
|
||||
|
||||
|
||||
# ── _row_local_house: разбор houses.address разных форматов источников ──────
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("row_address", "expected"),
|
||||
[
|
||||
(
|
||||
"р-н Чкаловский, мкр. Ботанический, улица Крестинского, 49к1",
|
||||
("р-н чкаловский мкр. ботанический крестинского", "49к1"),
|
||||
),
|
||||
("Хрустальногорская ул.,88/2", ("хрустальногорская", "88/2")),
|
||||
("ул. Начдива Онуфриева,24к2", ("начдива онуфриева", "24к2")),
|
||||
(
|
||||
"р-н Ленинский, мкр. Юго-Западный, улица Начдива Онуфриева, 24к1",
|
||||
("р-н ленинский мкр. юго-западный начдива онуфриева", "24к1"),
|
||||
),
|
||||
("Крестинского, 44", ("крестинского", "44")),
|
||||
# house-then-district order («·» separator, no comma before house) —
|
||||
# match-from-start of the LAST comma-segment still finds the leading token.
|
||||
("улица Хрустальногорская, 35к1 · р-н Академический", ("хрустальногорская", "35к1")),
|
||||
],
|
||||
)
|
||||
def test_row_local_house(row_address: str, expected: tuple[str, str]) -> None:
|
||||
assert _row_local_house(row_address) == expected
|
||||
|
||||
|
||||
def test_row_local_house_none_without_house_segment() -> None:
|
||||
"""Нет запятой (номер дома не отделён сегментом) → None, не гадаем."""
|
||||
assert _row_local_house("Крестинского") is None
|
||||
assert _row_local_house("") is None
|
||||
|
||||
|
||||
# ── _local_houses_match: full tier, mocked db ────────────────────────────────
|
||||
|
||||
|
||||
def _make_row(address: str, lat: float, lon: float) -> MagicMock:
|
||||
row = MagicMock()
|
||||
row.address = address
|
||||
row.lat = lat
|
||||
row.lon = lon
|
||||
return row
|
||||
|
||||
|
||||
def _db_with_rows(rows: list[MagicMock]) -> MagicMock:
|
||||
db = MagicMock()
|
||||
db.execute.return_value.fetchall.return_value = rows
|
||||
return db
|
||||
|
||||
|
||||
def test_local_houses_match_exact_house_number() -> None:
|
||||
"""«88/2» точно совпадает с единственной строкой houses — возвращает её координаты."""
|
||||
db = _db_with_rows(
|
||||
[
|
||||
_make_row("Хрустальногорская ул.,88", 56.79412, 60.498687),
|
||||
_make_row("Хрустальногорская ул.,88/2", 56.793218, 60.497106),
|
||||
]
|
||||
)
|
||||
|
||||
hit = _local_houses_match(db, "хрустальногорская", "88/2")
|
||||
|
||||
assert hit is not None
|
||||
assert isinstance(hit, GeocodeSuggestion)
|
||||
assert hit.lat == pytest.approx(56.793218)
|
||||
assert hit.lon == pytest.approx(60.497106)
|
||||
assert hit.kind == "house"
|
||||
|
||||
|
||||
def test_local_houses_match_street_tail_and_corpus1_guess() -> None:
|
||||
"""«Онуфриева, 24» (без «Начдива», без корпуса), реестр — ЕДИНСТВЕННЫЙ
|
||||
корпус «24к1» → уверенная догадка (нет sibling-корпусов — не угадайка)."""
|
||||
db = _db_with_rows(
|
||||
[
|
||||
_make_row(
|
||||
"р-н Ленинский, мкр. Юго-Западный, улица Начдива Онуфриева, 24к1",
|
||||
56.802928,
|
||||
60.551696,
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
hit = _local_houses_match(db, "онуфриева", "24")
|
||||
|
||||
assert hit is not None
|
||||
assert hit.lat == pytest.approx(56.802928)
|
||||
assert hit.lon == pytest.approx(60.551696)
|
||||
|
||||
|
||||
def test_local_houses_match_corpus1_guess_skipped_when_sibling_corpus_exists() -> None:
|
||||
"""#2626 review R2 #3, прод-данные: «Начдива Онуфриева, 24» реально ТРИ
|
||||
разных здания (24к1/24к2/24к3, 250-400м друг от друга). Догадка «→24к1»
|
||||
не угадывает конкретное здание среди known-siblings — честный None, не
|
||||
«уверенный» результат с confidence='exact' на случайно выбранном доме."""
|
||||
db = _db_with_rows(
|
||||
[
|
||||
_make_row(
|
||||
"р-н Ленинский, мкр. Юго-Западный, улица Начдива Онуфриева, 24к1",
|
||||
56.802928,
|
||||
60.551696,
|
||||
),
|
||||
_make_row("ул. Начдива Онуфриева,24к2", 56.802701, 60.554391),
|
||||
_make_row("Екатеринбург, улица Начдива Онуфриева, 24к3", 56.802041, 60.548283),
|
||||
]
|
||||
)
|
||||
|
||||
assert _local_houses_match(db, "онуфриева", "24") is None
|
||||
|
||||
|
||||
def test_local_houses_match_corpus1_guess_skipped_when_slash_sibling_exists() -> None:
|
||||
"""Sibling-guard ловит не только «кN», но и «/N» вариант того же номера."""
|
||||
db = _db_with_rows(
|
||||
[
|
||||
_make_row("улица X, 24к1", 56.80, 60.60),
|
||||
_make_row("улица X, 24/2", 56.81, 60.61),
|
||||
]
|
||||
)
|
||||
|
||||
assert _local_houses_match(db, "x", "24") is None
|
||||
|
||||
|
||||
def test_local_houses_match_no_corpus1_candidate_returns_none() -> None:
|
||||
"""Только «24к2»/«24к3» в реестре (нет «24к1») → фолбэк НЕ гадает, None."""
|
||||
db = _db_with_rows(
|
||||
[
|
||||
_make_row("ул. Начдива Онуфриева,24к2", 56.802701, 60.554391),
|
||||
_make_row("Екатеринбург, улица Начдива Онуфриева, 24к3", 56.802041, 60.548283),
|
||||
]
|
||||
)
|
||||
|
||||
assert _local_houses_match(db, "онуфриева", "24") is None
|
||||
|
||||
|
||||
def test_local_houses_match_ambiguous_exact_number_returns_none() -> None:
|
||||
"""Прод-кейс: «Крестинского, 49к1» встречается ДВАЖДЫ с РАЗНЫМИ координатами
|
||||
(две разные строки houses) — неоднозначность, фолбэк не угадывает, None."""
|
||||
db = _db_with_rows(
|
||||
[
|
||||
_make_row(
|
||||
"р-н Чкаловский, мкр. Ботанический, улица Крестинского, 49к1",
|
||||
56.789895,
|
||||
60.632464,
|
||||
),
|
||||
_make_row("Екатеринбург, улица Крестинского, 49к1", 56.7952695, 60.610079),
|
||||
]
|
||||
)
|
||||
|
||||
assert _local_houses_match(db, "крестинского", "49к1") is None
|
||||
|
||||
|
||||
def test_local_houses_match_ambiguous_corpus1_guess_returns_none() -> None:
|
||||
"""«49» → «49к1»-кандидатов больше одного (разные координаты) → None."""
|
||||
db = _db_with_rows(
|
||||
[
|
||||
_make_row("улица X, 49к1", 56.80, 60.60),
|
||||
_make_row("улица X, 49к1", 56.81, 60.61),
|
||||
]
|
||||
)
|
||||
|
||||
assert _local_houses_match(db, "x", "49") is None
|
||||
|
||||
|
||||
def test_local_houses_match_deduplicates_same_building_different_sources() -> None:
|
||||
"""Один и тот же дом, две source-строки (avito+cian) с ПОЧТИ идентичными
|
||||
координатами — НЕ считается неоднозначностью (дедуп по округлённым coords)."""
|
||||
db = _db_with_rows(
|
||||
[
|
||||
_make_row("улица X, 49к1", 56.800001, 60.600001),
|
||||
_make_row("улица X, 49к1", 56.800002, 60.600002), # тот же дом, другой source
|
||||
]
|
||||
)
|
||||
|
||||
hit = _local_houses_match(db, "x", "49к1")
|
||||
|
||||
assert hit is not None
|
||||
assert hit.lat == pytest.approx(56.800001)
|
||||
|
||||
|
||||
def test_local_houses_match_no_guess_for_non_digit_house() -> None:
|
||||
"""Запрос уже с литерой/корпусом («35к3»), точного совпадения нет — корпус-1
|
||||
ДОГАДКА не пробуется (не «35к3к1»), результат None."""
|
||||
db = _db_with_rows([_make_row("улица X, 35к4", 56.80, 60.60)])
|
||||
|
||||
assert _local_houses_match(db, "x", "35к3") is None
|
||||
|
||||
|
||||
def test_local_houses_match_returns_none_on_db_error() -> None:
|
||||
db = MagicMock()
|
||||
db.execute.side_effect = RuntimeError("connection lost")
|
||||
|
||||
assert _local_houses_match(db, "онуфриева", "24") is None
|
||||
|
||||
|
||||
# ── bbox guard: `houses` is NOT EKB-only (#2626 review R2 #2) ───────────────
|
||||
|
||||
|
||||
def test_local_houses_match_rejects_row_outside_ekb_bbox() -> None:
|
||||
"""Прод-кейс: «улица Маяковского, 7» в `houses` — это Серов (56.6/60.66 —
|
||||
~310км от ЕКБ), не Екатеринбург. `use_local_ekb` в `geocode()` гейтит только
|
||||
ЗАПРОС пользователя, не координаты строки-источника — bbox-фильтр внутри
|
||||
`_local_houses_match` обязан отбросить такую строку, а не вернуть её как
|
||||
confidence='exact' совпадение чужого города."""
|
||||
db = _db_with_rows(
|
||||
[_make_row("улица Маяковского, 7", 59.652903, 60.659674)], # Серов, не ЕКБ
|
||||
)
|
||||
|
||||
assert _local_houses_match(db, "маяковского", "7") is None
|
||||
|
||||
|
||||
def test_local_houses_match_accepts_row_inside_ekb_bbox_wide() -> None:
|
||||
"""Контроль: легитимная ЕКБ-строка (в т.ч. приграничье, в WIDE, не в TIGHT)
|
||||
по-прежнему проходит — bbox-фильтр не режет реальные ЕКБ-дома."""
|
||||
db = _db_with_rows(
|
||||
[_make_row("Екатеринбург, улица Маяковского, 8", 56.862701, 60.620274)],
|
||||
)
|
||||
|
||||
hit = _local_houses_match(db, "маяковского", "8")
|
||||
|
||||
assert hit is not None
|
||||
assert hit.lat == pytest.approx(56.862701)
|
||||
|
||||
|
||||
# ── deterministic ORDER BY (#2626 review R2 #5) ──────────────────────────────
|
||||
|
||||
|
||||
def test_local_houses_match_query_has_deterministic_order_by() -> None:
|
||||
"""Без ORDER BY дедуп по округлённым координатам оставлял бы ПЕРВУЮ строку
|
||||
в порядке сканирования — недетерминированно между вызовами. SQL обязан
|
||||
сортировать явно."""
|
||||
db = _db_with_rows([])
|
||||
|
||||
_local_houses_match(db, "x", "1")
|
||||
|
||||
sql_text = str(db.execute.call_args[0][0])
|
||||
assert "ORDER BY" in sql_text.upper()
|
||||
|
||||
|
||||
# ── geocode() wiring — last-resort tier, sets address_refined ───────────────
|
||||
|
||||
|
||||
async def test_geocode_falls_back_to_local_houses_after_nominatim_miss() -> None:
|
||||
"""Cache/geoportal/cadastral/Nominatim все промахнулись → local-houses тир
|
||||
вызывается ПОСЛЕДНИМ и помечает результат `address_refined=True`."""
|
||||
db = MagicMock()
|
||||
hit = GeocodeSuggestion(
|
||||
label="р-н Ленинский, мкр. Юго-Западный, улица Начдива Онуфриева, 24к1",
|
||||
full_address="р-н Ленинский, мкр. Юго-Западный, улица Начдива Онуфриева, 24к1",
|
||||
lat=56.802928,
|
||||
lon=60.551696,
|
||||
kind="house",
|
||||
)
|
||||
|
||||
with (
|
||||
patch("app.services.geocoder._cache_get", return_value=None),
|
||||
patch("app.services.geocoder._geoportal_house_match", return_value=None),
|
||||
patch("app.services.geocoder._cadastral_house_match", return_value=None),
|
||||
patch("app.services.geocoder._cadastral_forward_sync", return_value=[]),
|
||||
patch("app.services.geocoder._cache_put") as mock_cache_put,
|
||||
patch(
|
||||
"app.services.geocoder._nominatim_lookup",
|
||||
new_callable=AsyncMock,
|
||||
return_value=None,
|
||||
),
|
||||
patch(
|
||||
"app.services.geocoder._local_houses_match",
|
||||
return_value=hit,
|
||||
) as mock_local,
|
||||
):
|
||||
result = await geocode("ул Онуфриева, д 24", db)
|
||||
|
||||
assert result is not None
|
||||
assert result.lat == pytest.approx(56.802928)
|
||||
assert result.confidence == "exact"
|
||||
assert result.address_refined is True
|
||||
mock_local.assert_called_once()
|
||||
# #2626 review R2 #4 — houses-фолбэк дешёвый и менее надёжный источник
|
||||
# координат, чем geoportal/cadastral/Nominatim — свой результат не кэширует.
|
||||
mock_cache_put.assert_not_called()
|
||||
|
||||
|
||||
async def test_geocode_address_refined_false_when_earlier_tier_hits() -> None:
|
||||
"""geoportal-хит (обычный, точный ввод) НЕ помечается `address_refined` —
|
||||
флаг честно относится ТОЛЬКО к houses-фолбэку."""
|
||||
db = MagicMock()
|
||||
hit = GeocodeSuggestion(
|
||||
label="ул. Серова, д. 27, Екатеринбург",
|
||||
full_address="ул. Серова, д. 27, Екатеринбург",
|
||||
lat=56.81188,
|
||||
lon=60.59739,
|
||||
kind="house",
|
||||
)
|
||||
|
||||
with (
|
||||
patch("app.services.geocoder._cache_get", return_value=None),
|
||||
patch("app.services.geocoder._geoportal_house_match", return_value=hit),
|
||||
patch("app.services.geocoder._cache_put"),
|
||||
patch(
|
||||
"app.services.geocoder._local_houses_match",
|
||||
) as mock_local,
|
||||
):
|
||||
result = await geocode("Серова 27", db)
|
||||
|
||||
assert result is not None
|
||||
assert result.address_refined is False
|
||||
mock_local.assert_not_called()
|
||||
|
||||
|
||||
async def test_geocode_returns_none_when_local_houses_also_misses() -> None:
|
||||
"""Все тиры включая houses-фолбэк промахнулись → honest None (не выдумываем)."""
|
||||
db = MagicMock()
|
||||
|
||||
with (
|
||||
patch("app.services.geocoder._cache_get", return_value=None),
|
||||
patch("app.services.geocoder._geoportal_house_match", return_value=None),
|
||||
patch("app.services.geocoder._cadastral_house_match", return_value=None),
|
||||
patch("app.services.geocoder._cadastral_forward_sync", return_value=[]),
|
||||
patch("app.services.geocoder._cache_put"),
|
||||
patch(
|
||||
"app.services.geocoder._nominatim_lookup",
|
||||
new_callable=AsyncMock,
|
||||
return_value=None,
|
||||
),
|
||||
patch("app.services.geocoder._local_houses_match", return_value=None) as mock_local,
|
||||
):
|
||||
result = await geocode("ул Онуфриева, д 24", db)
|
||||
|
||||
assert result is None
|
||||
mock_local.assert_called_once()
|
||||
|
||||
|
||||
async def test_geocode_local_houses_apartment_number_does_not_leak_into_house() -> None:
|
||||
"""End-to-end regression, #2626 review R2 #1: реальный прод-адрес с хвостом
|
||||
«кв 11» должен резолвиться в дом 15 (`Педагогическая ул.,15`), а НЕ в дом 11
|
||||
(`Педагогическая ул.,11` — чужое здание) — `_local_houses_match` не
|
||||
замокан, проверяем полную цепочку `geocode()` → `_extract_local_house_token`
|
||||
→ SQL-lookup."""
|
||||
db = _db_with_rows(
|
||||
[
|
||||
_make_row("Педагогическая ул.,11", 56.835387, 60.654104),
|
||||
_make_row("Педагогическая ул.,15", 56.835284, 60.655829),
|
||||
]
|
||||
)
|
||||
|
||||
with (
|
||||
patch("app.services.geocoder._cache_get", return_value=None),
|
||||
patch("app.services.geocoder._geoportal_house_match", return_value=None),
|
||||
patch("app.services.geocoder._cadastral_house_match", return_value=None),
|
||||
patch("app.services.geocoder._cadastral_forward_sync", return_value=[]),
|
||||
patch("app.services.geocoder._cache_put") as mock_cache_put,
|
||||
patch(
|
||||
"app.services.geocoder._nominatim_lookup",
|
||||
new_callable=AsyncMock,
|
||||
return_value=None,
|
||||
),
|
||||
):
|
||||
result = await geocode(
|
||||
"620078, Свердловская обл, г Екатеринбург, Кировский р-н, "
|
||||
"ул Педагогическая, д 15, кв 11",
|
||||
db,
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
assert result.lat == pytest.approx(56.835284)
|
||||
assert result.lon == pytest.approx(60.655829)
|
||||
assert result.address_refined is True
|
||||
mock_cache_put.assert_not_called()
|
||||
Loading…
Add table
Reference in a new issue