gendesign/tradein-mvp/backend/app/services/geocoder.py
bot-backend 8fcec9f12e fix(tradein/observability): stop basic_auth 401 and RetryError GlitchTip noise
83% of tracker issues (7460 total) were pure noise drowning real signal:
- basic_auth 401 (3738 issues, 2019 distinct titles) — ops/glitchtip-auth-
  forwarder sent EVERY 401 from bots scanning gendsgn.ru (GET /wp-admin/
  install.php etc.) as an individual GlitchTip event, remote_ip baked into
  message/tags inflated cardinality. Not an application error — expected
  bot-scan traffic against a basic_auth-protected site.
- RetryError (2462 issues) — geocoder.py's three tenacity @retry-wrapped
  Nominatim helpers (lookup/suggest/reverse) raised tenacity.RetryError on
  exhaustion without reraise=True; RetryError.__str__() embeds a Future
  repr() with a memory address that differs every call, so GlitchTip
  grouped each exhausted retry as a distinct issue instead of one.

Fix at the source, not post-hoc issue cleanup:
- forwarder.py: before_send drops events tagged event_type in
  {basic_auth_failed, basic_auth_storm}; forwarder's own capture_exception
  (real script bugs) carries no such tag and passes through untouched.
- geocoder.py: reraise=True on all three @retry decorators — propagates
  the real underlying exception (stable type + stacktrace) instead of the
  unstable RetryError wrapper.
- sentry_scrub.stabilize_retry_error_fingerprint: belt-and-suspenders
  before_send hook, composed into both app/main.py and scheduler_main.py
  (geocoder runs in both processes — FastAPI request path and the
  overnight geocode_missing_listings batch). Collapses any RetryError that
  still slips through into one persistent issue per cause-exception type
  name only — never IP/address/listing-id.

Content-ful categories (OperationalError, city-sweep, harvest_quarter,
cian/avito/yandex sweep failures, scrape_freshness_check — ~700 issues)
are untouched: filters key off event_type tag / exception type name only.
2026-08-15 18:08:04 +03:00

1802 lines
103 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Geocoder service — address → lat/lon.
Стратегия:
- Cache lookup в `geocode_cache` (Postgres) — TTL 90 дней
- Cache miss → локальные ЕКБ-тиры (геопортал/кадастр) → fallback Nominatim
- Результат сохраняется в кэш для последующих вызовов
Используется в:
- /api/v1/trade-in/estimate (вход — адрес от пользователя)
- /api/v1/geocode/lookup (debug endpoint)
- scraper jobs (когда нужно по адресу определить координаты)
"""
from __future__ import annotations
import asyncio
import logging
import re
from dataclasses import dataclass, replace
from typing import Literal
import httpx
from sqlalchemy import text
from sqlalchemy.orm import Session
from tenacity import retry, stop_after_attempt, wait_exponential
from app.core.config import settings
from app.services import dadata
logger = logging.getLogger(__name__)
# ── Result type ──────────────────────────────────────────────────────────────
@dataclass(frozen=True, slots=True)
class GeocodeResult:
lat: float
lon: float
full_address: str
provider: Literal["nominatim", "cache"]
confidence: Literal["exact", "approximate", "locality"] = "approximate"
# #2576: True если город НЕ был указан пользователем (ни в тексте адреса, ни
# через `city_hint`) — т.е. итоговый город результата определил провайдер
# (или локальный ЕКБ-тир), а не вызывающий код. Не эвристика на «правильность»
# результата — честный сигнал «доверяй, но проверяй», чтобы вызывающий код мог
# понизить confidence / переспросить город у пользователя. См. `_resolve_city_for_geocode`.
city_ambiguous: bool = False
# ── EKB bounding boxes ───────────────────────────────────────────────────────
# Два bbox с разным назначением:
# * TIGHT — для geocoder-фильтрации результатов Yandex/Nominatim при опечатках
# (не вернуть Челябинск/Пермь как «совпадение» по фуззи-матчу). Узкий по дизайну.
# * WIDE — для ingest-guard на координаты, ПРИШЕДШИЕ ИЗВНЕ (avito_detail
# data-map-lat/lon). Чуть шире, чтобы не резать легитимное приграничье ЕКБ
# (Верхняя Пышма / Среднеуральск / Берёзовский), при этом отсекая
# Питер/Тюмень/Уфу (#1871). WIDE строго содержит TIGHT, поэтому всё, что
# прошло бы tight-фильтр, проходит и wide-guard.
# bbox = (lat_min, lat_max, lon_min, lon_max).
EKB_BBOX_TIGHT = (56.65, 56.95, 60.40, 60.85)
EKB_BBOX_WIDE = (56.6, 57.1, 60.3, 60.9)
def is_within_ekb_bbox(
lat: float, lon: float, bbox: tuple[float, float, float, float] = EKB_BBOX_TIGHT
) -> bool:
"""True если (lat, lon) внутри bbox (inclusive). bbox = (lat_min, lat_max, lon_min, lon_max)."""
lat_min, lat_max, lon_min, lon_max = bbox
return lat_min <= lat <= lat_max and lon_min <= lon <= lon_max
def is_within_ekb_bbox_wide(lat: float, lon: float) -> bool:
"""Ingest-guard: True если координаты в широком ЕКБ-bbox (#1871).
Используется для валидации координат из avito detail-страниц перед записью в БД.
Шире geocoder-tight, поэтому не режет легитимное приграничье, но отсекает не-ЕКБ
(Питер/Тюмень/Уфа).
"""
return is_within_ekb_bbox(lat, lon, EKB_BBOX_WIDE)
# ── Sverdlovsk oblast (region 66) ────────────────────────────────────────────
# Генеральный bbox всей области — от южной границы (ЕКБ/Полевской) до
# северных городов (Ивдель/Североуральск/Краснотурьинск). Специально щедрый:
# используется как fallback-accept для провайдеров БЕЗ структурного region-поля
# (сырые Point.pos от Yandex/Nominatim) — когда нет ФИАС/КЛАДР для точной
# проверки region==66. Дороже false-positive у самой границы соседних регионов,
# дешевле false-negative (выбросить корректный областной адрес — исходный баг).
# bbox = (lat_min, lat_max, lon_min, lon_max).
OBLAST66_BBOX = (55.8, 62.2, 56.7, 66.6)
# Nominatim viewport для областного (не только ЕКБ) поиска — покрывает всю
# область. viewbox — Nominatim-формат (lon1,lat1,lon2,lat2, NW→SE); ll/spn —
# legacy-поля того же словаря (были общим форматом с удалённым #2593 Yandex
# Geocoder-тиром), не используются Nominatim-кодом напрямую.
OBLAST66_VIEWBOX = {
"viewbox": "56.7,62.2,66.6,55.8",
"ll": "61.65,59.0",
"spn": "9.9,6.4",
}
def is_within_oblast66_bbox(lat: float, lon: float) -> bool:
"""True если (lat, lon) в генеральном bbox Свердловской области (регион 66)."""
return is_within_ekb_bbox(lat, lon, OBLAST66_BBOX)
# Крупные города области (кроме ЕКБ) — НЕ исчерпывающий список (в области ~30
# городов), покрывает основные региональные центры. Используется чтобы не
# навязывать "Екатеринбург, " в запрос, если пользователь уже указал другой
# город региона (геокодер forward/suggest prefix-логика).
SVERDLOVSK_OBLAST_CITIES = frozenset(
{
"екатеринбург",
"нижний тагил",
"каменск-уральский",
"первоуральск",
"серов",
"новоуральск",
"ревда",
"полевской",
"асбест",
"богданович",
"ирбит",
"красноуфимск",
"верхняя пышма",
"берёзовский",
"березовский",
"заречный",
"качканар",
"краснотурьинск",
"североуральск",
"ивдель",
"тавда",
"туринск",
"сысерть",
"среднеуральск",
"дегтярск",
"верхняя салда",
"нижняя салда",
"невьянск",
"артёмовский",
"артемовский",
"камышлов",
"алапаевск",
"сухой лог",
"кушва",
"красноуральск",
"карпинск",
"нижняя тура",
"верхний тагил",
"нижние серги",
# НЕ добавляем "лесной": ekb_geoportal_buildings содержит реальную ЕКБ-улицу
# «Лесной» (3 дома) — точное совпадение целого слова, не отличимое от
# города ЗАТО Лесной word-boundary'ом (в отличие от «Серова»/«Серов»,
# где разные словоформы). Бары-адрес "Лесной, 5" без "Екатеринбург" и
# без district-префикса ложно ушёл бы в non-EKB gate.
}
)
def known_city_hint(value: str | None) -> str | None:
"""`value` как city_hint, если это узнаваемое имя города региона 66, иначе None.
Для callers, которые берут город из КОЛОНКИ БД и передают его в `geocode()`
(#2603): `deals.city` — росреестровое поле, заполнено на 100%, но в хвосте
распределения лежит мусор («Бессонова», «Бердюгина», «Билейский
рыбопитомник» — улицы/урочища, попавшие в поле города). Мусорный хинт хуже
отсутствия хинта: любой не-ЕКБ `city_hint` жёстко закрывает EKB-локальные
тиры (`_ekb_local_tiers_allowed`) И подставляется префиксом в запрос
провайдеру (`_resolve_city_for_geocode`) — «Бессонова, Бессонова 10»
провайдер не резолвит вовсе.
Словарь — `SVERDLOVSK_OBLAST_CITIES`, тот же, на котором стоят городские
гейты самого геокодера. Отдельного списка сознательно НЕ заводим: город,
отсутствующий в этом наборе, и так обрабатывается геокодером как незнакомый
(`_names_non_ekb_city` его не увидит, `estimator._resolve_target_city` не
резолвит) — т.е. новый город области в любом случае добавляется СЮДА, и
гейт хинта не создаёт новой связности.
Цена решения (осознанная): легитимный, но не перечисленный населённый пункт
(«Реж», «Арамиль», сёла/посёлки) хинта не получит и вернётся к поведению «по
тексту адреса» — то же, что было до прошивки хинта, без регрессии.
Пользовательский ввод (`/geocode/lookup`, `/geocode/suggest`,
`TradeInEstimateInput.city_hint`) сюда НЕ заворачиваем: там город назвал
человек, и молча его игнорировать нельзя — для произвольной строки
fail-closed отрабатывает `_ekb_local_tiers_allowed` (#2580/#2589).
"""
if not value:
return None
return value if " ".join(value.lower().split()) in SVERDLOVSK_OBLAST_CITIES else None
# Значение для DaData-констрейнта `locations: [{"region": ...}]`.
# ВАЖНО: DaData хранит имя региона БЕЗ типа — `region="Свердловская"`,
# `region_type="обл"` (тип лежит в отдельных полях `region_type` /
# `region_with_type`). `locations` сравнивает именно с `region`, поэтому
# «Свердловская область» не совпадает НИ С ЧЕМ и hard-фильтр молча схлопывал
# выдачу в 0 подсказок (замер на проде: «Свердловская область» → 0 хитов,
# «Свердловская» → 5 хитов, первый — искомый «д 13б» с fias_id).
# Тип региона сюда дописывать нельзя — см. `test_dadata_region_constant_*`.
SVERDLOVSK_OBLAST_REGION = "Свердловская"
# Word/phrase-boundary regex — НЕ substring — чтобы «Серова 27» не матчил город
# «Серов», «Ирбитская 5» — «Ирбит», «Асбестовский пер.» — «Асбест», «Невьянский
# пер.» — «Невьянск», «Богдановича» — «Богданович» (все реальные EKB-адреса,
# substring-матч ложно ронял "Екатеринбург, "-префикс на них → неверный город
# → неверная цена). Python `\b` для str-паттернов юникод-осведомлён (Cyrillic
# входит в `\w`), поэтому «серова» корректно НЕ матчит `\bсеров\b` (после
# «серов» идёт «а» — тоже \w, границы нет).
_OBLAST_CITY_RE = re.compile(
r"\b(?:" + "|".join(re.escape(city) for city in SVERDLOVSK_OBLAST_CITIES) + r")\b"
)
_OBLAST_MARKER_RE = re.compile(r"\bсвердловск\w*\b")
# Приставки, после которых токен обозначает РАЙОН/микрорайон города, а не сам
# город региона: «мкр Заречный» — микрорайон ВНУТРИ другого города (не ЗАТО
# Заречный). Без этого исключения точное совпадение слова "заречный" всё равно
# ложно сработало бы (это не substring-баг, а реальная омонимия имени).
_DISTRICT_PREFIXES = frozenset({"мкр", "мкр.", "микрорайон", "р", "р-он", "район", "жк"})
def _has_oblast_marker(text_lower: str) -> bool:
"""True если текст уже содержит упоминание области/города региона 66.
Используется чтобы НЕ навязывать "Екатеринбург, " в запрос, когда адрес
уже привязан к другому городу/области — иначе получим двойной город
("Екатеринбург, Нижний Тагил, Ленина 10") и провайдер вернёт мусор/пусто.
Матчинг — по границе слова/фразы (см. `_OBLAST_CITY_RE`), НЕ substring —
и с исключением "мкр/микрорайон/р-н <город>" (район ВНУТРИ другого города).
"""
normalized = " ".join(text_lower.split())
if _OBLAST_MARKER_RE.search(normalized):
return True
for m in _OBLAST_CITY_RE.finditer(normalized):
prefix_words = normalized[: m.start()].split()
if prefix_words and prefix_words[-1] in _DISTRICT_PREFIXES:
continue # «мкр Заречный» — район, не город-ЗАТО Заречный
return True
return False
def _resolve_city_for_geocode(address: str, city_hint: str | None) -> tuple[str | None, bool]:
"""Определяет, какой город подставлять в запрос внешнему провайдеру
(Nominatim), когда сам текст адреса города не называет.
Приоритет:
1. Адрес уже содержит маркер города/области региона 66 (`_has_oblast_marker`)
→ город уже указан пользователем в тексте адреса, ничего подставлять не
нужно. Возвращает (None, True).
2. `city_hint` передан вызывающим кодом (например, фронт знает выбранный
город из предыдущего шага UI) → подставляем его. Возвращает (city, True).
3. Ни то, ни другое → раньше (#2576) здесь молча подставлялся "Екатеринбург"
— для жителей других городов области это давало уверенно неверную цену
(«Ленина, 1» в Нижнем Тагиле снапалось на екатеринбургскую улицу Ленина,
обе улицы называются одинаково). Теперь НЕ подставляем никакой город —
провайдер ищет по OBLAST66 viewbox/bbox (см. `OBLAST66_VIEWBOX`), без
привязки к конкретному городу. Возвращает
(None, False) — второй элемент False сигнализирует, что город
пользователь НЕ указывал (источник `GeocodeResult.city_ambiguous`).
Returns:
(city_or_none, city_specified_by_user).
"""
if _has_oblast_marker(address.lower()):
return None, True
hint = (city_hint or "").strip()
if hint:
return hint, True
return None, False
# Города региона 66 КРОМЕ Екатеринбурга — используется чтобы отсечь EKB-only
# локальные тиры (geoportal/cadastral, см. `geocode()`) от адреса другого
# города области. re.escape на элементах SVERDLOVSK_OBLAST_CITIES-{ekb}.
_NON_EKB_OBLAST_CITIES = SVERDLOVSK_OBLAST_CITIES - {"екатеринбург"}
_NON_EKB_CITY_RE = re.compile(
r"\b(?:" + "|".join(re.escape(c) for c in _NON_EKB_OBLAST_CITIES) + r")\b"
)
# Word-boundary, НЕ substring — «екатеринбургское шоссе» (реальная улица в
# городах-спутниках, напр. Первоуральске) не должно матчить как «Екатеринбург».
_EKATERINBURG_RE = re.compile(r"\bекатеринбург\b")
def _names_non_ekb_city(address: str) -> bool:
"""True если адрес явно называет город Свердловской области, ОТЛИЧНЫЙ от
Екатеринбурга (по границе слова, с исключением district-приставок «мкр/р
<город>»). Используется чтобы НЕ пускать EKB-only локальные тиры
(geoportal/cadastral) на не-ЕКБ адрес: улица+дом, совпадающие с ЕКБ-домом
(проспект Ленина 1 есть в обоих городах), иначе адрес снапается в ЕКБ (#11).
Явное "Екатеринбург" в адресе имеет приоритет над любым омонимом другого
города региона — иначе ЖК/посёлки с названием, совпадающим с другим
городом области (ЖК «Заречный», пос. «Сысерть» внутри ЕКБ), ложно
отсекали бы EKB-only тиры для реального ЕКБ-адреса.
"""
normalized = " ".join(address.lower().split())
# Явное "Екатеринбург" (отдельным словом — см. `_EKATERINBURG_RE`) в адресе
# → это ЕКБ, даже если в тексте встречается омоним соседнего города (ЖК
# «Заречный», пос. Сысерть в составе МО «город Екатеринбург» и т.п. —
# реальные false-positive из прод-кэша). Word-boundary же не даёт слову
# «Екатеринбургское» (шоссе в городах-спутниках) ложно сработать как город.
if _EKATERINBURG_RE.search(normalized):
return False
for m in _NON_EKB_CITY_RE.finditer(normalized):
prefix_words = normalized[: m.start()].split()
if prefix_words and prefix_words[-1] in _DISTRICT_PREFIXES:
continue # «мкр Заречный» — район внутри другого города, не ЗАТО Заречный
return True
return False
# ── Fail-closed EKB-local-tiers gate (#2582) ─────────────────────────────────
# `_names_non_ekb_city` выше ловит только 37 ПЕРЕЧИСЛЕННЫХ городов области —
# любой другой топоним (другой регион РФ целиком, ИЛИ областной город вне
# списка: Реж/Арамиль/Рефтинский/Лёвиха) молча проходил как «не назван», и
# EKB-only локальные тиры (geoportal/cad_buildings) снапали улицу+дом в ЕКБ по
# коллизии (см. живые случаи в geocode_cache: «республика крым, ялта, ...» →
# координаты ЕКБ, «челябинская обл., трёхгорный, ...» → координаты ЕКБ).
#
# Вместо перечисления (сама причина бага — список никогда не покроет все
# города РФ) используем общий структурный признак «в адресе назван
# город/регион»: маркер-слово, которое НИКОГДА не относится к улице/дому
# («обл.», «край», «республика», «г.», «пос.»).
#
# Два разных класса маркеров — разная форма regex:
# 1. Маркеры БЕЗ обязательной точки после (обл/область/край/республика/респ/
# г/пос/посёлок/поселок) — сохраняют старую форму `\bслово\.?\b`. Точка
# опциональна и есть трейлинг `\b`, который для этих слов срабатывает и
# без точки (следующий символ — пробел/запятая, не-словесный).
# 2. Маркеры-ОДНОБУКВЕННЫЕ сокращения, где точка ОБЯЗАТЕЛЬНА для отличия от
# других смыслов той же буквы («с» — типографский мусор/что угодно, «д» —
# дом-маркер `_HOUSE_MARKER`) — «с.», «д.», «дер.», «пгт.», «ст.». Тут
# хвостовой `\b` В ПРИНЦИПЕ не может сработать после точки перед пробелом
# (оба не-словесные — на стыке `.` и ` ` нет границы \w/не-\w) — было
# смерто-рождённой формой, вылавливавшей 0 из 104 прод-адресов «с. <село>»
# (deep-review PR #2589, H1). Вместо `\b` в конце — lookahead на кириллицу
# сразу после точки (с опциональными пробелами): `\.\s*(?=[а-яё])`.
# Это и различает «д. 5» (дом-маркер, номер, НЕ топоним — после точки
# цифра, lookahead не матчит) от «д. Кашино» (деревня — после точки буква,
# матчит) БЕЗ явного исключения "д." из списка: раньше "д." исключали
# целиком, потому что не было способа отличить дом от деревни — lookahead
# даёт этот способ, поэтому "д."/"дер." теперь можно включить безопасно.
_LOCALITY_MARKER_RE = re.compile(
r"\b(?:обл\.?|область|край|республика|респ\.?|г|пос\.?|посёлок|поселок)\b"
r"|\b(?:с|д|дер|пгт|ст)\.\s*(?=[а-яё])"
)
def _names_unrecognized_locality(address: str) -> bool:
"""True если адрес называет топоним, но это не Екатеринбург и не город из
известного списка `SVERDLOVSK_OBLAST_CITIES` (#2582).
Отличает «город не назван» (bare «Ленина 1» — так приходит основной
трафик формы оценки; geoportal резолвит его как ЕКБ и это оправданный
дефолт) от «назван НЕЗНАКОМЫЙ город» (Ялта/Трёхгорный/Лёвиха, «с. Кашино»,
«д. Родина» — структурный маркер топонима есть, но это не ЕКБ и не
известный город региона 66) — только во втором случае EKB-only локальные
тиры нельзя пускать.
Известная дыра (сознательно, не «баг» — закрывать architectural-решением
отдельно, не перечислением городов): голый «Реж, Ленина 5» / «Арамиль,
Мира 1» БЕЗ структурного маркера («обл.»/«г.»/«с.» и т.п.) и без города
из списка `SVERDLOVSK_OBLAST_CITIES` не ловится — падает в «город не
назван» и уходит в ЕКБ-дефолт. Все живые прод-подмены (geocode_cache)
были с маркером/региональным префиксом — см. тесты `test_ekb_local_tiers_allowed`
(изолирующий кейс на голое «реж, ленина 5» зафиксирован как известная дыра).
"""
normalized = " ".join(address.lower().split())
if _EKATERINBURG_RE.search(normalized):
return False # явный ЕКБ — приоритет, см. _names_non_ekb_city
return bool(_LOCALITY_MARKER_RE.search(normalized))
# ── Постфактум-инвариант подмены города (#2590) ──────────────────────────────
# Гейты выше (#2582/#2589) стоят НА ВХОДЕ и решают, пускать ли ЕКБ-only тиры.
# Внешние провайдеры ими не покрыты: «реж, ленина» уходит в Nominatim/Yandex, и
# тот, не найдя Режа, отдаёт улицу Ленина в Екатеринбурге. Отличить на входе
# «Реж» (город) от «Малышева» (улица) без списка городов нельзя — оба «слово до
# запятой». ПОСЛЕ ответа можно: провайдер сам пишет, какой населённый пункт он
# использовал, и если названный топоним туда не дожил — топоним подменён.
#
# Инвариант (#2590): назван топоним ≠ Екатеринбург + его нет в ответе провайдера
# + ответ лежит внутри ЕКБ ⇒ результат недостоверен. Ни одного имени города в
# коде — только уровни РФ-адреса (страна → регион → район → НП → улица → дом) и
# сам целевой город.
_ADDRESS_SEGMENT_RE = re.compile(r"[,;·]")
# Страна: сегмент выше уровня НП. Единственная константа-топоним помимо целевого
# города — продукт РФ-only, новых значений у неё не появится (в отличие от
# списка городов области, ради ухода от которого всё и делается).
_COUNTRY_RE = re.compile(r"\b(?:росси[яи]|russia)\b")
# Уровень «улица/дом»: дойдя до него, НП уже был бы назван (порядок РФ-адреса
# big→small). Дальше идти нельзя — иначе второй уличный сегмент («малышева,
# мопра» — перекрёсток) читается как топоним и ложно отбраковывается.
_STREET_LEVEL_RE = re.compile(
r"\b(?:ул|улица|пер|переулок|пр|пр-кт|пркт|проспект|б-р|бульвар|ш|шоссе|наб|набережная"
r"|пл|площадь|проезд|тракт|аллея|тупик|туп|линия|кв-л|квартал|стр|строение|дом|корп"
r"|корпус|лит|литера|снт|сад|гск)\b"
)
# Уровни ВЫШЕ и НИЖЕ населённого пункта — пропускаем и идём дальше по сегментам:
# «свердловская обл., г.о. рефтинский» (регион → НП), «мкр-н широкая речка, ул.
# …» (район ВНУТРИ города — его провайдер в ответе обычно не повторяет).
_REGION_LEVEL_RE = re.compile(r"\b(?:обл\.?|область|края|край|республика|респ\.?|ао)\b")
_DISTRICT_LEVEL_RE = re.compile(r"\b(?:р-н|р-он|район|мкр|мкр-н|микрорайон|жк|жилой)\b")
# Слова-ТИПЫ НП (не имя): «пос. Кедровка» → имя «кедровка». Тип не сравнивается
# с ответом — провайдер пишет свой («посёлок» vs «пос.»), имя же обязано дожить.
_LOCALITY_TYPE_WORDS = frozenset(
{
"поселок",
"пос",
"село",
"деревня",
"дер",
"город",
"гор",
"округ",
"муниципальный",
"городской",
"сельское",
"поселение",
"тер",
"территория",
"станция",
"пгт",
"рп",
}
)
_WORD_RE = re.compile(r"[а-я][а-я-]*")
def _fold(value: str) -> str:
"""lower + ё→е + схлопывание пробелов — общий канон для сравнения топонимов."""
return " ".join(value.lower().replace("ё", "е").split())
def _claimed_locality(address: str) -> str | None:
"""Имя населённого пункта, названное в тексте адреса, или None.
Структурно, БЕЗ перечисления городов: идём по сегментам в порядке РФ-адреса
(страна → регион → район → НП → улица → дом), пропускаем уровни выше/ниже
НП, останавливаемся на уровне улицы/дома. Первый оставшийся сегмент — имя НП.
None означает «НП не назван» — это основной трафик формы («Малышева 30»), и
для него инвариант не применяется вовсе.
"""
for segment in _ADDRESS_SEGMENT_RE.split(_fold(address)):
segment = segment.strip()
if not segment:
continue
if any(ch.isdigit() for ch in segment) or _STREET_LEVEL_RE.search(segment):
return None # улица/дом: будь НП назван, он шёл бы раньше
if (
_COUNTRY_RE.search(segment)
or _REGION_LEVEL_RE.search(segment)
or _DISTRICT_LEVEL_RE.search(segment)
):
continue
name = " ".join(
w for w in _WORD_RE.findall(segment) if w not in _LOCALITY_TYPE_WORDS and len(w) >= 3
)
if name:
return name
return None
def _city_substituted(address: str, result: GeocodeResult) -> bool:
"""True если провайдер подменил названный в адресе НП Екатеринбургом (#2590).
Три условия вместе:
1. в адресе назван НП и это не Екатеринбург (`_claimed_locality`);
2. этого имени НЕТ в адресе, который вернул провайдер — то есть топоним не
пережил геокодинг;
3. результат лежит внутри ЕКБ: и по координатам (`EKB_BBOX_TIGHT`), и по
собственному ответу провайдера — он называет Екатеринбург либо не
называет НП вовсе (ЕКБ-only локальные реестры отдают «Улица, дом»;
тогда «внутри ЕКБ» подтверждают координаты).
Условие 3 и разводит подмену с посёлками в городской черте. «пос. Кедровка,
Советская ул., 5» → ответ «Екатеринбург, Советская улица, 5» — имя не дожило,
и это ПРАВДА подмена: настоящая Кедровка в 20 км от улицы Советской. А
корректный ответ по посёлку («Кедровка, Екатеринбург, …» — Nominatim и Yandex
пишут НП всегда, когда действительно его нашли) имя сохраняет и через фильтр
не проходит. Проверяется не география посёлка, а факт «топоним потерян».
Известный потолок: НП, чьё имя совпало с уличным токеном ответа («Ачит» ⊂
«М-12 Ачит-Екатеринбург», «Лесной» ⊂ «Лесной переулок»), считается дожившим —
пропуск, не ложная отбраковка. Обратный потолок: жилрайон ЕКБ, названный без
приставки («пионерский, советская»), понижается до `locality` — честная
деградация, координаты не теряются.
"""
claimed = _claimed_locality(address)
if claimed is None or _EKATERINBURG_RE.search(claimed):
return False
answer = _fold(result.full_address or "")
if any(word in answer for word in claimed.split()):
return False # топоним дожил до ответа — провайдер искал там, где просили
if not is_within_ekb_bbox(result.lat, result.lon):
return False
answer_locality = _claimed_locality(answer)
return answer_locality is None or bool(_EKATERINBURG_RE.search(answer_locality))
def _ekb_local_tiers_allowed(address: str, city_hint: str | None = None) -> bool:
"""Fail-closed гейт локальных ЕКБ-тиров geocoder (`geocode()`/`suggest()`, #2582).
ЕКБ-only локальные реестры (geoportal/cad_buildings) используются ТОЛЬКО
когда Екатеринбург подтверждён явно (текстом адреса или `city_hint`), или
нигде не назван никакой город/регион. Гейт закрывается, если сработал
ЛЮБОЙ из трёх независимых сигналов (OR, не приоритетная цепочка):
- известный город региона 66 в ТЕКСТЕ адреса (`_names_non_ekb_city`, без
изменений, #11);
- `city_hint` ЗАДАН и НЕ является Екатеринбургом (`hint_blocks` ниже) —
#2580 (C1) + #2589 (deep-review M2): `city_hint` — явный СТРУКТУРНЫЙ
вход от caller'а (не свободный текст адреса), поэтому для него
fail-closed форма ОБРАТНАЯ по сравнению с текстом адреса: «не
Екатеринбург → закрыто», а не «есть в списке 37 → закрыто». Список
37 годился бы, только если единственный риск — известные города; но
`city_hint` принимает произвольную строку без валидации (в т.ч. через
API напрямую, минуя 6-городный selector UI) — «Реж»/любой седьмой
город, добавленный в реестр, до сих пор не в списке 37 → список-based
проверка держала гейт открытым, geoportal отдавал точный ЕКБ-дом, а
`city_ambiguous=False` (хинт ведь БЫЛ) делало это уверенно-неверным —
худший случай (без hint был бы честный `city_ambiguous=True`);
- незнакомый топоним в ТЕКСТЕ адреса — структурный маркер
(`_names_unrecognized_locality`, #2582: «обл./край/республика/г./пос.»
и однобуквенные «с./д./дер./пгт./ст.» с lookahead на кириллицу после
точки) — покрывает города вне списка 37 С маркером (Лёвиха, «с.
Новоалексеевское») и ДРУГИЕ РЕГИОНЫ РФ целиком (Ялта, Трёхгорный), не
требуя их перечислять. Голый город без маркера и вне списка (Реж без
«обл.») — известная дыра, см. `_names_unrecognized_locality`.
До #2582 решение по третьему пункту принималось от противного: «нет в
списке 37 городов → считаем Екатеринбургом» — молча ломало любой адрес
вне списка (см. docstring `_names_unrecognized_locality`).
Note: т.к. это OR трёх независимых сигналов (не приоритетная цепочка),
`city_hint`, противоречащий явному "Екатеринбург" в тексте адреса
(`_names_non_ekb_city(address)` для такого текста вернёт False и не
закроет гейт сам по себе — Екатеринбург в тексте не мешает), тем не менее
ЗАКРОЕТ гейт через `hint_blocks` — тот же trade-off, что уже был одобрен
в #2580 (`use_local_ekb`), сюда не переписываем. Нормальный кейс — caller
не передаёт противоречащие address и city_hint одновременно.
"""
hint = " ".join((city_hint or "").lower().split())
hint_blocks = bool(hint) and not _EKATERINBURG_RE.search(hint)
return not (
_names_non_ekb_city(address) or hint_blocks or _names_unrecognized_locality(address)
)
# ── Address normalisation ───────────────────────────────────────────────────
def normalize_address(address: str) -> str:
"""Нормализация для cache lookup: lowercase + trim + collapse whitespace.
« Ул. МАЛЫШЕВА, 30 » → «ул. малышева, 30»
"""
return " ".join(address.lower().strip().split())
def _cache_key(address_norm: str, city_hint: str | None) -> str:
"""Ключ `geocode_cache.address_normalized` — адрес, дополненный городом,
если он известен вызывающему коду.
#2576: раньше ключ был просто нормализованный адрес — одинаковый для
«Ленина, 1» независимо от того, кто спрашивает (ЕКБ или Нижний Тагил).
Т.к. геокодер раньше молча предполагал ЕКБ, оба города писали/читали ОДНУ
и ту же строку кэша → взаимная порча (первый запрос «застолбил» город для
второго). С `city_hint` разные города для одного текста адреса больше не
делят один ключ.
БЕЗ `city_hint` формат ключа не меняется (backward-compatible с уже
накопленным кэшем) — коллизия между городами для запросов без hint'а
остаётся возможной (структурно неизбежно, пока вызывающий код не начнёт
передавать city_hint повсеместно), но `city_ambiguous` на результате
честно сигнализирует об этом вызывающему.
"""
city_norm = " ".join((city_hint or "").lower().strip().split())
if not city_norm:
return address_norm
return f"{address_norm}|city={city_norm}"
# Согласные, которые часто пишут с одной буквой вместо двух (RU typos).
_DOUBLE_CONSONANTS = "лнмссккттпп"
def _typo_variants(query: str, limit: int = 6) -> list[str]:
"""Генерация вариантов с удвоением согласных — для случая когда пользователь
написал «Цвилинга» вместо «Цвиллинга», «Толстова» вместо «Толстого» итд.
Каждая позиция где есть одинокая согласная — кандидат на удвоение.
Возвращаем до `limit` вариантов в порядке вероятности (ближе к началу слова → выше).
"""
if len(query) < 3:
return []
variants: list[str] = []
chars = list(query)
for i in range(1, len(chars) - 1):
c = chars[i].lower()
if c not in _DOUBLE_CONSONANTS:
continue
prev_c = chars[i - 1].lower()
next_c = chars[i + 1].lower()
# Удваиваем только если соседи — гласные/'ь'/'ъ' (характерно для русских типо)
if prev_c in "аеёиоуыэюяьъ" and next_c in "аеёиоуыэюяьъ":
variant = query[: i + 1] + chars[i] + query[i + 1 :]
if variant != query and variant not in variants:
variants.append(variant)
if len(variants) >= limit:
break
return variants
# ── Cache ────────────────────────────────────────────────────────────────────
def _cache_get(db: Session, address_norm: str) -> GeocodeResult | None:
row = db.execute(
text(
"""
SELECT lat, lon, full_address, provider, confidence
FROM geocode_cache
WHERE address_normalized = :addr
AND expires_at > NOW()
"""
),
{"addr": address_norm},
).fetchone()
if row is None:
return None
return GeocodeResult(
lat=row.lat,
lon=row.lon,
full_address=row.full_address,
provider="cache",
confidence=row.confidence or "approximate",
)
def _cache_put(db: Session, address_norm: str, result: GeocodeResult) -> None:
db.execute(
text(
"""
INSERT INTO geocode_cache
(address_normalized, lat, lon, full_address, provider, confidence)
VALUES (:addr, :lat, :lon, :full, :provider, :conf)
ON CONFLICT (address_normalized) DO UPDATE
SET lat = EXCLUDED.lat,
lon = EXCLUDED.lon,
full_address = EXCLUDED.full_address,
provider = EXCLUDED.provider,
confidence = EXCLUDED.confidence,
created_at = NOW(),
expires_at = NOW() + interval '90 days'
"""
),
{
"addr": address_norm,
"lat": result.lat,
"lon": result.lon,
"full": result.full_address,
"provider": result.provider,
"conf": result.confidence,
},
)
db.commit()
# ── Provider: Nominatim (OSM, без ключа) ────────────────────────────────────
def _nominatim_region_ok(item: dict) -> bool | None:
"""Кросс-чек региона по Nominatim `address.state` (доступно т.к. addressdetails=1).
True/False если state однозначно про/не про Свердловскую область. None если
поле отсутствует/не строка — тогда accept-логика падает обратно на bbox.
Ловит Тюмень/Шадринск/Кунгур/Снежинск — они внутри генерального OBLAST66_BBOX
(специально щедрого), но их state явно другой регион.
"""
addr = item.get("address")
if not isinstance(addr, dict):
return None
state = addr.get("state")
if not isinstance(state, str) or not state:
return None
return "свердловск" in state.lower()
async def _nominatim_query(client: httpx.AsyncClient, address: str) -> dict | None:
"""Single Nominatim search. Возвращает лучший item или None.
ВАЖНО: фильтруем результаты по bbox области (region 66) прямо тут, чтобы при
опечатках не возвращать Пермский край / Челябинск — но не резать легитимные
Нижний Тагил / Серов и т.д. (генеральный bbox всей Свердловской области).
Two-pass tie-break: среди кандидатов предпочитаем того, кто попадает в TIGHT
ЕКБ-bbox (byte-identical для ЕКБ-запросов, даже если Nominatim ранжировал его
не первым) — иначе первый кандидат внутри OBLAST66. Плюс region cross-check
(`address.state`) — отсекает кандидатов ЯВНО из другого региона (Тюмень и
т.п.), даже если координаты попали в генеральный bbox.
"""
response = await client.get(
"https://nominatim.openstreetmap.org/search",
params={
"q": address,
"format": "json",
"limit": "3",
"countrycodes": "ru",
"addressdetails": "1",
"viewbox": OBLAST66_VIEWBOX["viewbox"],
"bounded": "1", # строго в пределах области (region 66)
},
)
response.raise_for_status()
data = response.json()
oblast_fallback: dict | None = None
for item in data:
try:
lat_f = float(item["lat"])
lon_f = float(item["lon"])
except Exception:
continue
if _nominatim_region_ok(item) is False:
continue # регион явно не Свердловская область — не рассматриваем
if is_within_ekb_bbox(lat_f, lon_f):
return item # tight-ЕКБ приоритетнее — тот же результат, что и раньше
if oblast_fallback is None and is_within_oblast66_bbox(lat_f, lon_f):
oblast_fallback = item
return oblast_fallback
# reraise=True (GlitchTip-noise fix): без него tenacity на исчерпанных ретраях
# бросает СВОЙ tenacity.RetryError, чей str() тащит repr() последнего Future
# (`<Future at 0x...>` — адрес объекта в памяти, разный на КАЖДЫЙ вызов). GlitchTip
# группирует по этому нестабильному тексту → одна и та же причина (Nominatim
# недоступен/rate-limit) плодила отдельный issue на каждое исчерпание ретраев
# (2 462 issue из 7 461 в трекере). reraise=True пробрасывает РЕАЛЬНОЕ исключение
# (httpx.HTTPStatusError/TimeoutException) — стабильный тип+стек, группируется
# нормально. Дополнительная страховка — stabilize_retry_error_fingerprint
# (app/observability/sentry_scrub.py) на случай если RetryError всплывёт откуда-то ещё.
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=8), reraise=True)
async def _nominatim_lookup(address: str, city_hint: str | None = None) -> GeocodeResult | None:
"""OSM Nominatim — бесплатно, без ключа, 1 req/sec policy.
Бан-policy: User-Agent с email обязателен.
Tier 1: bounded область (region 66) на оригинальный адрес.
Tier 2: bounded область (region 66) на typo-варианты (Цвилинга → Цвиллинга).
#2580 (C): city_hint, если известен, подставляется в текст запроса — без
него `_nominatim_query` полагается ТОЛЬКО на oblast66-bbox фильтр + tie-break
(предпочитает tight-ЕКБ bbox), который для одноимённых улиц ВНУТРИ региона
(напр. "Ленина" — и в Екатеринбурге, и в с. Свердловское) не различает город.
Эмпирически подтверждено: "Ленина 1" без города → случайное село внутри
области; "Нижний Тагил, Ленина 1" → корректно резолвится. Nominatim —
единственный живой внешний провайдер (#2593: Yandex Geocoder удалён) —
city_hint должен реально влиять на его результат, не только на кэш-ключ.
"""
headers = {
"User-Agent": f"TradeInMVP/0.1 (contact: {settings.contact_email})",
"Accept": "application/json",
"Accept-Language": "ru,en;q=0.8",
"Referer": "https://tradein-mvp.local/",
}
city, _ = _resolve_city_for_geocode(address, city_hint)
query = f"{city}, {address}" if city else address
async with httpx.AsyncClient(timeout=10.0, headers=headers) as client:
# Tier 1: оригинал
item = await _nominatim_query(client, query)
# Tier 2: typo-variants
if item is None:
for variant in _typo_variants(address, limit=4):
await asyncio.sleep(1.0) # Nominatim 1 req/sec policy
variant_city, _ = _resolve_city_for_geocode(variant, city_hint)
variant_query = f"{variant_city}, {variant}" if variant_city else variant
item = await _nominatim_query(client, variant_query)
if item is not None:
logger.info("nominatim typo-fixed: %s%s", address, variant)
break
if item is None:
return None
confidence = "exact" if item.get("class") == "building" else "approximate"
return GeocodeResult(
lat=float(item["lat"]),
lon=float(item["lon"]),
full_address=item.get("display_name", address),
provider="nominatim",
confidence=confidence,
)
# ── Suggest (автокомплит) ───────────────────────────────────────────────────
@dataclass(frozen=True, slots=True)
class GeocodeSuggestion:
label: str # формат "Малышева 30, Октябрьский район"
full_address: str # полный из геокодера
lat: float
lon: float
kind: str # 'house' / 'street' / 'locality'
# ГАР OBJECTGUID (ФИАС) дома — заполняется ТОЛЬКО для house-level кандидатов
# DaData-тира (fias_level 8/9). У Nominatim/cadastral-тиров и у
# street/locality-кандидатов остаётся None (нет стабильного house-fias).
fias_id: str | None = None
# ── DaData suggest wrapper ──────────────────────────────────────────────────
# Маппинг DaData kind → GeocodeSuggestion.kind (внутри geocoder используется
# 'locality' вместо 'city' — consistent с Nominatim-веткой).
_DADATA_KIND_MAP = {"house": "house", "street": "street", "city": "locality"}
async def _dadata_suggest(query: str, limit: int = 8) -> list[GeocodeSuggestion]:
"""Обёртка над `dadata.suggest_addresses` — конвертит в GeocodeSuggestion.
Дроп candidate'ов без координат (DaData возвращает их для широких categories
типа город/район, для autocomplete с привязкой к карте они бесполезны).
Label собирается из DaData `value` (короткая форма «ул Малышева, д 30»).
Constraint — вся область (region=`SVERDLOVSK_OBLAST_REGION`, hard-filter
внутри `suggest_addresses`), а не один город ЕКБ — иначе Нижний Тагил/
Серов/etc никогда не появились бы в подсказках.
"""
raw = await dadata.suggest_addresses(
query, limit=limit, city=None, region=SVERDLOVSK_OBLAST_REGION
)
if not raw:
# Region-констрейнт — hard-filter: неверное значение схлопывает выдачу в
# 0 БЕЗ ошибки (так и жил баг «Свердловская область» → 0 подсказок).
# Отдельный warning, чтобы следующая такая регрессия была видна в логах,
# а не выглядела как «DaData ничего не знает про этот адрес».
logger.warning(
"dadata suggest: 0 кандидатов для %r при region=%r"
"проверь, что констрейнт совпадает с полем DaData `region` (без типа)",
query[:60],
SVERDLOVSK_OBLAST_REGION,
)
out: list[GeocodeSuggestion] = []
for s in raw:
if s.lat is None or s.lon is None:
continue
mapped_kind = _DADATA_KIND_MAP.get(s.kind, "locality")
# ФИАС отдаём наружу ТОЛЬКО для house-level кандидата (DaData kind='house'
# ⇔ fias_level 8/9). Для street/city/plot fias_id указывает не на дом —
# не годится как ключ матчинга целевого дома, оставляем None.
out.append(
GeocodeSuggestion(
label=s.value,
full_address=s.unrestricted_value,
lat=s.lat,
lon=s.lon,
kind=mapped_kind,
fias_id=s.fias_id if mapped_kind == "house" else None,
)
)
return out
async def _nominatim_query_multi(client: httpx.AsyncClient, query: str, limit: int) -> list[dict]:
"""Один Nominatim search с фильтром по bbox области (region 66). Возвращает up to N items."""
response = await client.get(
"https://nominatim.openstreetmap.org/search",
params={
"q": query,
"format": "json",
"limit": str(limit),
"countrycodes": "ru",
"viewbox": OBLAST66_VIEWBOX["viewbox"],
"bounded": "1",
"addressdetails": "1",
},
)
response.raise_for_status()
data = response.json()
return data if isinstance(data, list) else []
def _dedupe_nominatim_items(*item_lists: list[dict]) -> list[dict]:
"""Объединяет несколько списков raw Nominatim items в один, без дублей.
Дедуп по `place_id` (если есть), иначе по округлённым координатам. Порядок
сохраняется: элементы из более раннего списка идут первыми (приоритет).
"""
seen: set[tuple[object, ...]] = set()
out: list[dict] = []
for items in item_lists:
for item in items:
place_id = item.get("place_id")
key: tuple[object, ...]
if place_id is not None:
key = ("place_id", place_id)
else:
try:
key = ("latlon", round(float(item["lat"]), 5), round(float(item["lon"]), 5))
except (KeyError, ValueError, TypeError):
key = ("raw", item.get("display_name"))
if key in seen:
continue
seen.add(key)
out.append(item)
return out
async def _nominatim_query_city_aware(
client: httpx.AsyncClient, query: str, city: str | None, city_specified: bool, limit: int
) -> list[dict]:
"""Строит и выполняет Nominatim-запрос(ы) с учётом того, известен ли город.
Три случая (см. `_resolve_city_for_geocode`):
1. `city` не None (`city_hint` подставлен) → один suffixed-запрос с ним.
2. `city` is None, но `city_specified=True` (маркер УЖЕ в тексте адреса,
например "Екатеринбург, Малышева 30") → запрос БЕЗ доп. суффикса — город
уже есть в тексте, дублировать его нельзя (иначе "X, Екатеринбург,
Екатеринбург" ломает матчинг).
3. `city` is None и `city_specified=False` — город НЕизвестен вообще (#2580 /
C2, regression test "Победы 20"): один bare-запрос БЕЗ текстового суффикса
неожиданно теряет часть настоящих ЕКБ-адресов — Nominatim без подсказки о
городе иногда предпочитает street-level матч в соседнем городе-спутнике
(напр. "Победы 20" без суффикса → улица Победы, Верхняя Пышма) более
специфичному named-place матчу в ЕКБ ("Парк Победы" ЖК, Екатеринбург).
Поэтому делаем ДВА запроса — bare (честный oblast-wide поиск, не теряет
реальные адреса других городов) И ЕКБ-suffixed (majority трафика) — и
ОБЪЕДИНЯЕМ результаты (не заменяем один другим): оба честных кандидата
остаются в списке, пользователь выбирает нужный сам из подсказок.
ЕКБ-кандидаты идут первыми (majority-случай, привычный порядок).
"""
if city:
return await _nominatim_query_multi(client, f"{query}, {city}", limit)
if city_specified:
return await _nominatim_query_multi(client, query, limit)
ekb_data = await _nominatim_query_multi(client, f"{query}, Екатеринбург", limit)
await asyncio.sleep(1.0) # Nominatim 1 req/sec policy — два запроса подряд
bare_data = await _nominatim_query_multi(client, query, limit)
return _dedupe_nominatim_items(ekb_data, bare_data)[:limit]
# reraise=True — см. комментарий у `_nominatim_lookup` (GlitchTip RetryError-шум).
@retry(stop=stop_after_attempt(2), wait=wait_exponential(multiplier=1, min=1, max=4), reraise=True)
async def _nominatim_suggest(
query: str, limit: int = 8, city_hint: str | None = None
) -> list[GeocodeSuggestion]:
"""Nominatim в режиме suggest. С typo-fallback (для случаев когда оригинальный
запрос ничего не находит).
Суффикс города навязывается, только если он известен: адрес уже называет
город/область региона 66, либо передан явный `city_hint`. Если город
НЕизвестен — см. `_nominatim_query_city_aware` (dual-query, C2): раньше
(#2576) здесь молча подставлялся суффикс ", Екатеринбург" всегда; чистое
удаление суффикса (без dual-query) регрессило часть реальных ЕКБ-адресов
(см. C2 в #2580) — поэтому оба честных варианта объединяются, не заменяют
друг друга.
"""
headers = {
"User-Agent": f"TradeInMVP/0.1 (contact: {settings.contact_email})",
"Accept": "application/json",
"Accept-Language": "ru,en;q=0.8",
}
city, city_specified = _resolve_city_for_geocode(query, city_hint)
async with httpx.AsyncClient(timeout=8.0, headers=headers) as client:
# Tier 1: оригинальный query
data = await _nominatim_query_city_aware(client, query, city, city_specified, limit)
# Tier 2: typo-варианты если оригинал пустой
if not data:
for variant in _typo_variants(query, limit=3):
await asyncio.sleep(1.0) # Nominatim 1 req/sec
variant_city, variant_specified = _resolve_city_for_geocode(variant, city_hint)
data = await _nominatim_query_city_aware(
client, variant, variant_city, variant_specified, limit
)
if data:
logger.info("nominatim suggest typo-fixed: %s%s", query, variant)
break
out: list[GeocodeSuggestion] = []
for item in data:
display = item.get("display_name", "")
addr = item.get("address", {}) or {}
# Компактный лейбл: street + house_number / locality / district
street = addr.get("road") or addr.get("street") or ""
house = addr.get("house_number", "")
district = addr.get("suburb") or addr.get("city_district") or addr.get("borough") or ""
parts = []
if street:
parts.append(f"{street}{f', {house}' if house else ''}")
elif item.get("name"):
parts.append(item["name"])
if district:
parts.append(district)
label = " · ".join(parts) if parts else display[:80]
kind = "house" if house else ("street" if street else "locality")
out.append(
GeocodeSuggestion(
label=label,
full_address=display,
lat=float(item["lat"]),
lon=float(item["lon"]),
kind=kind,
)
)
return out
def _cadastral_forward_sync(db: Session, query: str, limit: int = 5) -> list[GeocodeSuggestion]:
"""Forward search via gendesign_cad_buildings FDW.
Uses ILIKE prefilter (FDW-pushable) + pg_trgm similarity ranking locally.
Returns up to `limit` suggestions, or [] on error / no results.
"""
try:
rows = (
db.execute(
text("""
SELECT cad_num, readable_address, lat, lon,
similarity(readable_address, CAST(:q AS text)) AS sim
FROM gendesign_cad_buildings
WHERE readable_address ILIKE CAST('%' || :q || '%' AS text)
ORDER BY sim DESC, length(readable_address) ASC
LIMIT CAST(:lim AS integer)
"""),
{"q": query, "lim": limit * 4},
)
.mappings()
.all()
)
except Exception:
logger.warning("cadastral forward sync failed for query=%r", query, exc_info=True)
return []
out: list[GeocodeSuggestion] = []
for r in rows[:limit]:
out.append(
GeocodeSuggestion(
label=str(r["readable_address"]),
full_address=str(r["readable_address"]),
lat=float(r["lat"]),
lon=float(r["lon"]),
kind="house",
)
)
return out
# ── Street+house parser (для anchored cadastral match) ──────────────────────
# Маркеры типов улиц (для извлечения street-токена). Порядок длинных перед
# короткими чтобы 'проспект' матчился раньше 'пр'.
_STREET_TYPE = (
r"улица|проспект|переулок|бульвар|шоссе|набережная|площадь|"
r"пр-?кт|пр-?т|б-?р|наб|тракт|пл|аллея|туп|проезд|ул|пер|ш"
)
# Дом-маркеры: «д 35», «дом 35», «строение 5», «стр 5», «сооружение 1»
_HOUSE_MARKER = r"(?:д|дом|строение|стр|сооружение|соор)"
# Номер дома: цифры + опционально ОДНА кириллическая буква («26а», «77»).
_HOUSE_NUM = r"\d+\s*-?\s*[а-яё]?"
_RE_POSTAL = re.compile(r"\b\d{6}\b")
_RE_OBLAST = re.compile(r"свердловск(?:ая)?\s*(?:обл(?:асть)?\.?)")
_RE_CITY = re.compile(r"(?:г\.?|город)\s*екатеринбург|екатеринбург")
# Район: одно (опц. дефисное) слово перед «р-н/район». Узко — чтобы не съесть улицу.
_RE_DISTRICT = re.compile(r"\b[а-яё-]+\s+(?:р-?н|район)\b")
# Хвостовой мусор: квартира/корпус/литера — маркер ОБЯЗАН быть с числом и отделён
# запятой/пробелом (иначе «к» съел бы «Космонавтов»).
_RE_APT_TAIL = re.compile(
r"[,\s]\s*(?:кв|квартира|оф|офис|пом|помещение|корп|корпус|к|лит)\.?\s*\d.*$"
)
_RE_HOUSE_WITH_MARKER = re.compile(rf"\b{_HOUSE_MARKER}\.?\s*({_HOUSE_NUM})", re.IGNORECASE)
_RE_STREET_AFTER_TYPE = re.compile(
rf"\b(?:{_STREET_TYPE})\.?\s+([а-яё0-9][а-яё0-9 .'-]*?)"
rf"(?=\s*,|\s*\b{_HOUSE_MARKER}\b|\s+\d|$)",
re.IGNORECASE,
)
_RE_NORM_NUM = re.compile(r"\s*-?\s*")
def _norm_house(num: str) -> str:
"""«26 а» / «26-а» → «26а»; «77» → «77». Сжимает пробел/дефис перед литерой."""
num = num.strip().lower()
m = re.match(r"(\d+)\s*-?\s*([а-яё]?)", num)
if not m:
return num
return m.group(1) + m.group(2)
def _parse_street_house(address: str) -> tuple[str, str] | None:
"""Парсит адрес в (street_lower, house_lower) или None если не разобрать.
Поддерживает DaData-форму, bare «Улица Дом», корпус/квартира-мусор,
улицы начинающиеся с цифры («8 Марта 204»).
Defensive: при неоднозначности возвращает None, а не гадает.
"""
if not address or not address.strip():
return None
s = " ".join(address.lower().strip().split())
# 1. Чистим geo-префиксы и хвостовой мусор (квартира/корпус)
s = _RE_POSTAL.sub(" ", s)
s = _RE_OBLAST.sub(" ", s)
s = _RE_DISTRICT.sub(" ", s)
s = _RE_CITY.sub(" ", s)
s = _RE_APT_TAIL.sub(" ", s)
s = " ".join(s.split()).strip(" ,.")
if not s:
return None
# 2. Дом: сначала из «д./дом N» группы, иначе — трейлинг-номер
house: str | None = None
m_house = _RE_HOUSE_WITH_MARKER.search(s)
if m_house:
house = _norm_house(m_house.group(1))
else:
m_tail = re.search(r"(\d+\s*-?\s*[а-яё]?)\s*$", s)
if m_tail:
house = _norm_house(m_tail.group(1))
if not house:
return None
# 3. Улица: токен после street-type маркера, иначе bare-форма (всё до номера)
street: str | None = None
m_street = _RE_STREET_AFTER_TYPE.search(s)
if m_street:
street = m_street.group(1).strip(" ,.'-")
else:
# bare-форма: «серова 27», «8 марта 204» — улица = всё до дом-группы/номера
head = s
if m_house:
head = s[: m_house.start()]
else:
head = re.sub(r"\s*\d+\s*-?\s*[а-яё]?\s*$", "", s)
street = head.strip(" ,.'-")
if not street:
return None
# Защита: улица не должна быть голым числом/мусором
if not re.search(r"[а-яё]", street):
return None
street = " ".join(street.split())
return (street, house)
# Извлечение номера дома из `readable_address` реестра. Реальные формы в
# gendesign_cad_buildings (47k строк, замер 2026-08-02):
# «д. 13» / «дом 13» / «сооружение 30» — 21k
# «д. 13б» — 2.6k
# «д. 13-б» — 2.1k
# «д. 13 б» — 125
# «д. 58/3», «д. 64-2» — 0.9k (угловые/корпусные номера)
# «д. 11 (кв. 1-150)», «д. 102 корпус 1» — хвост, литерой НЕ является
# Разбор:
# \m… — маркер только с НАЧАЛА слова, иначе «проезд 8
# Марта, д 5» дало бы дом «8» (старый `д\.?` без
# границы слова ловил «д» внутри «проезд»)
# [0-9]+ — номер
# (\s*[-/]\s*[0-9]+)? — «58/3» / «64-2»: часть номера, а не мусор —
# иначе «58» ложно совпало бы с «58/3»
# (\s*-?\s*[а-яё](?![а-яё]))? — литера; lookahead отсекает начало слова
# («102 корпус 1» → «102», не «102к»)
_SQL_HOUSE_TOKEN_RE = (
r"\m(?:дом|д\.?|строение|стр\.?|сооружение|соор\.?)\s*"
r"([0-9]+(?:\s*[-/]\s*[0-9]+)?(?:\s*-?\s*[а-яё](?![а-яё]))?)"
)
# Нормализация извлечённого токена к канону `_norm_house`: убираем пробелы,
# затем дефис ТОЛЬКО перед литерой («23-б» → «23б», но «64-2» остаётся «64-2»,
# иначе он схлопнулся бы в реальный дом «642»).
_SQL_HOUSE_TOKEN_NORM = (
r"regexp_replace("
r" regexp_replace("
r" lower(COALESCE((regexp_match(readable_address, :house_token_re, 'i'))[1], '')),"
r" '\s', '', 'g'),"
r" '-([а-яё])', '\1', 'g')"
)
def _cadastral_house_match(db: Session, street: str, house: str) -> GeocodeSuggestion | None:
"""Anchored cadastral match: ILIKE по улице + СТРОГОЕ равенство номера дома.
Литера — часть идентичности дома, а не украшение: «Новгородцевой 13б» и
«Новгородцевой 13» — РАЗНЫЕ здания. Поэтому номер сравнивается равенством
нормализованных форм (обе стороны → канон «13б»), а не «совпали цифры,
литера опциональна».
Раньше в regex шли только ЦИФРЫ дома, литера была опциональна в WHERE и
участвовала лишь как tie-break в ORDER BY — из-за чего запрос с литерой
молча получал соседний дом БЕЗ неё (и наоборот: «Малышева 30» → «д. 30-б»),
причём с `confidence="exact"` и записью в `geocode_cache` на 90 дней.
Regex-anchor на «д./дом/строение» (prefilter) сохранён: он дёшев, пушится
в FDW и убивает ложный матч номера внутри «(1-83)»-диапазона. Точность
даёт равенство токенов ниже.
`street` идёт ТОЛЬКО в bound-param ILIKE, номер дома в regex больше НЕ
конкатенируется (сравнивается как текст) — regex-injection поверхность
сузилась до цифр prefilter'а.
Нет дома с нужной литерой → возвращаем None, а НЕ «похожий» дом: пусть
отработают следующие тиры. Тихо подставленный соседний дом здесь
необратимо помечался бы `exact`.
ВНИМАНИЕ, цепочки различаются — не путать:
* `geocode()` : geoportal → cadastral → `_cadastral_forward_sync`
→ Nominatim → None. Тира DaData тут НЕТ.
* `suggest()` : cadastral → DaData → Nominatim (единственный вызов
`_dadata_suggest`).
То есть на прямом вызове `geocode()` (API/PDF/восстановление по `?id=`)
адрес с литерой, неизвестный ни геопорталу, ни Nominatim, даёт None —
оценка не строится. Это сознательный выбор: честный отказ вместо
уверенно-неверной оценки чужого дома. Основной UI-путь этим не задет —
координаты приходят из выбранной подсказки (`ParamsPanel.tsx:776` →
`api/v1/trade_in.py:128` использует lat/lon напрямую, минуя `geocode()`).
"""
house_norm = _norm_house(house)
house_digits_m = re.match(r"\d+", house_norm)
if not house_digits_m:
return None
house_digits = house_digits_m.group(0)
try:
row = db.execute(
text(
r"""
SELECT readable_address, lat, lon
FROM gendesign_cad_buildings
WHERE readable_address ILIKE CAST('%' || :street || '%' AS text)
AND readable_address ~* (
'\.?|дом|строение|стр\.?|сооружение|соор\.?)\s*'
|| CAST(:house_digits AS text)
|| '\s*[-./ ]?\s*[а-яё]?(\D|$)'
)
AND readable_address ~* 'екатеринбург'
AND readable_address !~* (
'(п\.\s|пос[. ]|посёлок|поселок|северка|шабровский'
|| '| км|снт|гараж|коллективный сад)'
)
AND """
+ _SQL_HOUSE_TOKEN_NORM
+ r""" = CAST(:house_norm AS text)
ORDER BY length(readable_address) ASC
LIMIT 1
"""
),
{
"street": street,
"house_digits": house_digits,
"house_norm": house_norm,
"house_token_re": _SQL_HOUSE_TOKEN_RE,
},
).first()
except Exception:
logger.warning(
"cadastral house match failed for street=%r house=%r",
street,
house,
exc_info=True,
)
return None
if row is None:
return None
return GeocodeSuggestion(
label=str(row.readable_address),
full_address=str(row.readable_address),
lat=float(row.lat),
lon=float(row.lon),
kind="house",
)
_RE_HOUSE_INTERNAL_SPACES = re.compile(r"\s+")
def _normalize_geoportal_house(house: str) -> str:
"""house_norm как в ekb_geoportal_buildings: lower + удаление внутренних пробелов.
«7 б» → «7б». ДОЛЖНО совпадать с app.tasks.ekb_geoportal_ingest.normalize_house.
"""
return _RE_HOUSE_INTERNAL_SPACES.sub("", house.strip().lower())
def _geoportal_house_match(db: Session, street: str, house: str) -> GeocodeSuggestion | None:
"""Точный матч по реестру зданий ЕКБ (городской геопортал, ekb_geoportal_buildings).
Полнее чем NSPD cad_buildings (~70% зданий ЕКБ отсутствуют в NSPD) — поэтому это
ПЕРВЫЙ локальный tier геокодера, до cad_buildings.
Нормализация: street_norm = lower(trim), house_norm = lower без внутренних пробелов
— ровно как наполняет лоадер. Параметры идут только bound-param'ами (без инъекций).
"""
street_norm = street.strip().lower()
house_norm = _normalize_geoportal_house(house)
if not street_norm or not house_norm:
return None
try:
row = db.execute(
text("""
SELECT street, house, lat, lon
FROM ekb_geoportal_buildings
WHERE street_norm = lower(trim(CAST(:street AS text)))
AND house_norm = CAST(:house AS text)
LIMIT 1
"""),
{"street": street_norm, "house": house_norm},
).first()
except Exception:
logger.warning(
"geoportal house match failed for street=%r house=%r",
street,
house,
exc_info=True,
)
return None
if row is None:
return None
label = f"{row.street}, {row.house}"
return GeocodeSuggestion(
label=label,
full_address=label,
lat=float(row.lat),
lon=float(row.lon),
kind="house",
)
def _cadastral_reverse_sync(db: Session, lat: float, lon: float, radius_m: int = 200) -> str | None:
"""Reverse lookup via gendesign_cad_buildings FDW.
bbox prefilter (±0.0025° lat, ±0.005° lon ≈ 280m) is FDW-pushable.
Distance calculated locally on small result set. Filters гаражи/СНТ.
Returns None on no result or error.
"""
try:
row = db.execute(
text("""
WITH candidates AS (
SELECT cad_num, readable_address, lat, lon,
111320.0 * sqrt(
pow(CAST(:lat AS double precision) - lat, 2) +
pow(
cos(radians(CAST(:lat AS double precision)))
* (CAST(:lon AS double precision) - lon), 2
)
) AS dist_m
FROM gendesign_cad_buildings
WHERE lat BETWEEN CAST(:lat AS double precision) - 0.0025
AND CAST(:lat AS double precision) + 0.0025
AND lon BETWEEN CAST(:lon AS double precision) - 0.005
AND CAST(:lon AS double precision) + 0.005
AND readable_address !~* '(гараж|снт|садовод|товарищ|уч\\.)'
)
SELECT readable_address, dist_m
FROM candidates
WHERE dist_m < CAST(:radius AS double precision)
ORDER BY dist_m ASC
LIMIT 1
"""),
{"lat": lat, "lon": lon, "radius": float(radius_m)},
).first()
except Exception:
logger.warning("cadastral reverse sync failed for (%.5f, %.5f)", lat, lon, exc_info=True)
return None
if row is None:
return None
return str(row.readable_address)
async def suggest(
query: str, db: Session | None = None, limit: int = 8, city_hint: str | None = None
) -> list[GeocodeSuggestion]:
"""Автокомплит адресов в Свердловской области (region 66; ЕКБ — основной трафик,
остаётся быстрым fast-path). Cadastral FDW → DaData → Nominatim → [].
db: если передан — cadastral lookup через gendesign_cad_buildings (первый tier).
city_hint: город, если известен вызывающему коду (#2576) — прокидывается в
Nominatim-тир, чтобы НЕ подставлять "Екатеринбург" молча, когда
пользователь его не называл. Опционально, backward-compatible (None —
прежнее поведение минус молчаливый EKB-дефолт, см. `_resolve_city_for_geocode`).
DaData /suggest (PR Q2) — token-only, 10k/день, лучший fit для РФ-адресов
(#2593: Yandex Geocoder, который был primary external provider до DaData,
удалён). DaData region-constraint уже охватывает всю область (не только
ЕКБ) — city_hint ей не нужен.
Без кэша (дешёво, провайдеры толерируют автокомплит-запросы).
"""
if not query or len(query.strip()) < 2:
return []
# Tier 1: cadastral FDW (если db доступна) — самый быстрый, без внешних запросов.
# EKB-only fail-closed гейт (#2582, было #11) — пропускаем, если query явно
# называет другой топоним: известный город области (`_names_non_ekb_city`),
# незнакомый — структурный маркер (`_names_unrecognized_locality`), ИЛИ
# `city_hint` называет город региона 66, отличный от ЕКБ (#2580 C1 —
# иначе хинт мёртвый параметр для этого тира, см. `_ekb_local_tiers_allowed`
# и `geocode()` ниже — тот же гейт). Внешние тиры (2/3 ниже) не гейтим —
# они уже oblast-aware.
if db is not None and _ekb_local_tiers_allowed(query, city_hint):
# 1a. Anchored house-match: парсим street+house → точный матч по дом-маркеру.
# Решает кейс «Серова 27» где raw-ILIKE по readable_address давал 0 hits.
parsed = _parse_street_house(query.strip())
if parsed is not None:
street, house = parsed
hit = await asyncio.to_thread(_cadastral_house_match, db, street, house)
if hit is not None:
return [hit]
# 1b. Fallback: legacy raw-ILIKE forward search (для нераспарсенных форм)
cad_results = await asyncio.to_thread(_cadastral_forward_sync, db, query.strip(), limit)
if cad_results:
return cad_results
# Tier 2: DaData /suggest — token-only (X-Secret не нужен), 10k/день free,
# лучший fit для РФ адресов.
if settings.dadata_api_token:
try:
dadata_results = await _dadata_suggest(query, limit)
if dadata_results:
return dadata_results
except Exception:
logger.exception("dadata suggest failed, falling back to nominatim")
# Tier 3: Nominatim (последний fallback — OSM, без ключа)
try:
return await _nominatim_suggest(query, limit, city_hint=city_hint)
except Exception:
logger.exception("nominatim suggest failed")
return []
# ── Public API ───────────────────────────────────────────────────────────────
async def geocode(address: str, db: Session, city_hint: str | None = None) -> GeocodeResult | None:
"""Геокодинг с кэшем + постфактум-проверка подмены города (#2590).
Тонкая обёртка над `_geocode_resolve` (вся тировая цепочка там). Инвариант
применяется ОДНОЙ точкой на выходе — поэтому покрывает разом все источники,
включая попадание в кэш: отравленная запись, записанная до этого фикса,
больше не отдаётся как точная, хотя строка в `geocode_cache` не тронута
(обратимо: откат кода возвращает прежнее поведение, чистить БД не требуется).
Сработал инвариант → `confidence="locality"` + `city_ambiguous=True`.
`locality` — не косметика: `estimator._geocode_is_coarse` уже трактует его
как «геокодер дошёл только до центра НП» и (а) включает #693 coarse-downgrade
оценки, (б) через `tasks.geocode_missing` проставляет листингу
`geo_precision='city'`, а этот признак исключает листинг из пула аналогов
(`estimator`/`location_index`: `geo_precision IS DISTINCT FROM 'city'`).
То есть объявление, уехавшее координатами в чужой город, перестаёт тянуть
за собой чужие оценки. Координаты НЕ выбрасываются — деградация честная и
видимая, а не отказ.
"""
result = await _geocode_resolve(address, db, city_hint)
if result is None or not _city_substituted(address, result):
return result
logger.warning(
"geocode city substitution (#2590): %r%r (%.5f, %.5f) provider=%s"
"названный НП не дожил до ответа, результат внутри ЕКБ; confidence→locality",
address[:80],
(result.full_address or "")[:80],
result.lat,
result.lon,
result.provider,
)
return replace(result, confidence="locality", city_ambiguous=True)
async def _geocode_resolve(
address: str, db: Session, city_hint: str | None = None
) -> GeocodeResult | None:
"""Геокодинг с кэшем. Cadastral FDW → Nominatim → None.
Args:
address: пользовательский ввод (может быть грязным — нормализуем).
db: сессия Postgres для cache lookup/write и cadastral FDW lookup.
city_hint: город, если известен вызывающему коду (#2576) — например
выбран пользователем на предыдущем шаге UI. Опциональный, не
ломает существующий контракт. Прокидывается в Nominatim-тир
вместо молчаливой подстановки "Екатеринбург" и
участвует в cache-ключе (см. `_cache_key`), чтобы ответы для
разных городов по одному и тому же тексту адреса не перезатирали
друг друга.
Returns:
GeocodeResult или None если ни один провайдер не отвечает.
`result.city_ambiguous=True`, если ни адрес, ни `city_hint` не
называли город явно — итоговый город определил провайдер/локальный
тир, а не пользователь (честный сигнал, не эвристика на корректность).
"""
if not address or len(address.strip()) < 3:
return None
_, city_specified = _resolve_city_for_geocode(address, city_hint)
city_ambiguous = not city_specified
addr_norm = _cache_key(normalize_address(address), city_hint)
# 1. Cache (sync DB-IO → offload в threadpool, чтобы не блокировать event loop)
cached = await asyncio.to_thread(_cache_get, db, addr_norm)
if cached is not None:
logger.info("geocode cache hit: %s", addr_norm)
return replace(cached, city_ambiguous=city_ambiguous)
# 2. Локальные источники по street+house (без внешнего API).
parsed = _parse_street_house(address.strip())
# #2582 (было #11): EKB-only локальные тиры (geoportal/cadastral) не должны
# отвечать на адрес другого города/региона — иначе улица+дом, коллизящие с
# ЕКБ-домом (напр. "проспект Ленина 1" есть и в Нижнем Тагиле, и в ЕКБ),
# снапаются в ЕКБ. Fail-closed: разрешаем локальные тиры ТОЛЬКО если
# Екатеринбург подтверждён явно (текстом или `city_hint`, #2580 C1 —
# иначе caller, явно передавший city_hint="Нижний Тагил" для "Ленина 1"
# без города в самом тексте, всё равно получал бы "точный" ЕКБ-хит с
# city_ambiguous=False — ложно-уверенный неверный результат), или
# город/регион вообще не назван нигде — см. `_ekb_local_tiers_allowed`.
# Раньше решение по тексту адреса принималось от противного (список из 37
# городов — «нет в списке → считаем ЕКБ»), из-за чего любой другой регион
# РФ (Ялта, Трёхгорный) молча резолвился в координаты ЕКБ (#2582).
use_local_ekb = _ekb_local_tiers_allowed(address, city_hint)
# 2a. Геопортал ЕКБ — ПЕРВЫЙ локальный tier (полнее cad_buildings ~на 70%).
if use_local_ekb and parsed is not None:
street, house = parsed
try:
hit = await asyncio.to_thread(_geoportal_house_match, db, street, house)
except Exception:
logger.warning("geoportal house-match raised — fall through", exc_info=True)
hit = None
if hit is not None:
result = GeocodeResult(
lat=hit.lat,
lon=hit.lon,
full_address=hit.full_address,
provider="cache",
confidence="exact",
city_ambiguous=city_ambiguous,
)
await asyncio.to_thread(_cache_put, db, addr_norm, result)
logger.info(
"geocode geoportal house-match: %s → (%.5f, %.5f)",
addr_norm,
result.lat,
result.lon,
)
return result
# 2c. Cadastral FDW (прямой запрос к gendesign_cad_buildings — без внешнего API)
# Anchored house-match: парсим street+house → точный матч по дом-маркеру.
# raw-ILIKE по полному readable_address давал 0 hits для «Серова 27» / DaData-форм
# (литеральная подстрока не совпадает).
if use_local_ekb and parsed is not None:
street, house = parsed
hit = await asyncio.to_thread(_cadastral_house_match, db, street, house)
if hit is not None:
result = GeocodeResult(
lat=hit.lat,
lon=hit.lon,
full_address=hit.full_address,
provider="nominatim", # treat as "local" — same confidence as nominatim
confidence="exact",
city_ambiguous=city_ambiguous,
)
await asyncio.to_thread(_cache_put, db, addr_norm, result)
logger.info(
"geocode cadastral house-match: %s → (%.5f, %.5f)",
addr_norm,
result.lat,
result.lon,
)
return result
# 2d. Fallback: legacy raw-ILIKE forward search (для нераспарсенных форм)
if use_local_ekb:
cad_suggestions = await asyncio.to_thread(
_cadastral_forward_sync, db, address.strip(), limit=1
)
if cad_suggestions:
s = cad_suggestions[0]
result = GeocodeResult(
lat=s.lat,
lon=s.lon,
full_address=s.full_address,
provider="nominatim", # treat as "local" — same confidence as nominatim
confidence="exact",
city_ambiguous=city_ambiguous,
)
await asyncio.to_thread(_cache_put, db, addr_norm, result)
logger.info(
"geocode cadastral fdw: %s → (%.5f, %.5f)", addr_norm, result.lat, result.lon
)
return result
# 3. Nominatim fallback
try:
result = await _nominatim_lookup(address, city_hint)
if result is not None:
result = replace(result, city_ambiguous=city_ambiguous)
await asyncio.to_thread(_cache_put, db, addr_norm, result)
logger.info("geocode nominatim: %s → (%.5f, %.5f)", addr_norm, result.lat, result.lon)
# Nominatim rate-limit policy: 1 req/sec — спим после успешного запроса
await asyncio.sleep(1.0)
return result
except Exception:
logger.exception("nominatim geocoder failed")
return None
# ── Reverse: координаты → адрес (для map-picker'а) ──────────────────────────
# Precision levels which we treat as "снап к зданию имеет смысл":
# - exact — точный матч на здание. #2593: единственный живой источник
# (`_yandex_reverse`) удалён — значение сейчас недостижимо ни одним из
# оставшихся тиров (cadastral/Nominatim), но оставлено в множестве:
# дёшево держать (frozen set membership-check, не enum), сохраняет parity
# с фронтовым `SNAP_PRECISIONS` (`MapPicker.tsx`, тот же литерал), и не
# ломает `test_snap_precision_useful_exact_and_number`.
# - number — найден дом с номером (то что нам надо для квартирного оценщика)
# - cadastral — Cadastral FDW row (та же точность, что и "number")
# Остальные (street/range/near/locality/other) → не снапаем, marker остаётся на клике.
_SNAP_PRECISIONS = {"exact", "number", "cadastral"}
@dataclass(frozen=True, slots=True)
class ReverseGeocodeResult:
"""Reverse-геокодинг с snapped координатами matched здания.
- `address` — текстовый адрес (улица + дом + город).
- `snapped_lat` — координата центра здания если provider дал её,
иначе echo `lat` входной точки (для precision=street/locality).
- `snapped_lon` — то же.
- `precision` — `exact`/`number`/`street`/`range`/`near`/`locality`/
`other`/`cadastral`. Используется фронтом чтобы
решить — двигать marker (exact/number) или нет.
- `provider` — кто дал результат (`nominatim`/`cadastral`).
Фронт MapPicker'а после клика смотрит на precision: если `exact`/`number`
и snapped >5m от click point — пересаживает marker на snapped point
(чтобы пользователь видел центр дома, а не свой клик во дворе).
Для остальных precision marker остаётся где кликнули — не врём что нашли
точное здание.
"""
address: str
snapped_lat: float
snapped_lon: float
precision: str
provider: Literal["nominatim", "cadastral"]
def _format_reverse_address(addr: dict) -> str | None:
"""Собирает чистый уличный адрес из Nominatim address-объекта.
Nominatim `display_name` ведёт с названия ближайшего POI/организации
(«NataliOlympic, 2к1, Трамвайный переулок, …»). Для map-picker'а это
ломает кейс: такой адрес нельзя forward-геокодировать обратно и оценка
не находит аналогов. Берём именно улицу + дом + город.
Возвращает None если в объекте нет улицы — тогда вызывающий код
откатывается на display_name.
"""
road = addr.get("road") or addr.get("pedestrian") or addr.get("footway")
if not road:
return None
house = addr.get("house_number")
city = addr.get("city") or addr.get("town") or addr.get("village") or addr.get("municipality")
parts: list[str] = [str(road)]
if house:
parts.append(str(house))
if city:
parts.append(str(city))
return ", ".join(parts)
# reraise=True — см. комментарий у `_nominatim_lookup` (GlitchTip RetryError-шум).
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=8), reraise=True)
async def _nominatim_reverse(lat: float, lon: float) -> ReverseGeocodeResult | None:
"""Nominatim /reverse → ReverseGeocodeResult с snapped coords из item.lat/lon.
Nominatim возвращает координаты центра matched feature (building/way/node).
precision выводим из наличия `house_number` в addressdetails.
"""
headers = {
"User-Agent": f"TradeInMVP/0.1 (contact: {settings.contact_email})",
"Accept": "application/json",
"Accept-Language": "ru,en;q=0.8",
}
async with httpx.AsyncClient(timeout=10.0, headers=headers) as client:
response = await client.get(
"https://nominatim.openstreetmap.org/reverse",
params={
"lat": str(lat),
"lon": str(lon),
"format": "json",
"addressdetails": "1",
"zoom": "18",
},
)
response.raise_for_status()
data = response.json()
if not isinstance(data, dict) or "error" in data:
return None
addr = data.get("address")
address_text: str | None = None
if isinstance(addr, dict):
address_text = _format_reverse_address(addr)
if not address_text:
display = data.get("display_name")
if not display:
return None
address_text = str(display)
# snapped coords — то что вернул Nominatim (центр matched feature)
try:
snapped_lat = float(data["lat"])
snapped_lon = float(data["lon"])
except (KeyError, ValueError, TypeError):
snapped_lat, snapped_lon = lat, lon
has_house = isinstance(addr, dict) and bool(addr.get("house_number"))
precision = "number" if has_house else "street"
return ReverseGeocodeResult(
address=address_text,
snapped_lat=snapped_lat,
snapped_lon=snapped_lon,
precision=precision,
provider="nominatim",
)
def _cadastral_reverse_sync_full(
db: Session, lat: float, lon: float, radius_m: int = 200
) -> tuple[str, float, float] | None:
"""Полный вариант cadastral reverse — возвращает (address, snapped_lat, snapped_lon).
Отдельная функция чтобы старый `_cadastral_reverse_sync` (только адрес)
остался backward-compatible — его держит `tests/services/test_cadastral_reverse.py`.
"""
try:
row = db.execute(
text("""
WITH candidates AS (
SELECT cad_num, readable_address, lat, lon,
111320.0 * sqrt(
pow(CAST(:lat AS double precision) - lat, 2) +
pow(
cos(radians(CAST(:lat AS double precision)))
* (CAST(:lon AS double precision) - lon), 2
)
) AS dist_m
FROM gendesign_cad_buildings
WHERE lat BETWEEN CAST(:lat AS double precision) - 0.0025
AND CAST(:lat AS double precision) + 0.0025
AND lon BETWEEN CAST(:lon AS double precision) - 0.005
AND CAST(:lon AS double precision) + 0.005
AND readable_address !~* '(гараж|снт|садовод|товарищ|уч\\.)'
)
SELECT readable_address, lat, lon, dist_m
FROM candidates
WHERE dist_m < CAST(:radius AS double precision)
ORDER BY dist_m ASC
LIMIT 1
"""),
{"lat": lat, "lon": lon, "radius": float(radius_m)},
).first()
except Exception:
logger.warning("cadastral reverse full failed for (%.5f, %.5f)", lat, lon, exc_info=True)
return None
if row is None:
return None
return (str(row.readable_address), float(row.lat), float(row.lon))
async def reverse_geocode(
lat: float, lon: float, db: Session | None = None
) -> ReverseGeocodeResult | None:
"""Cadastral FDW → Nominatim. Возвращает snapped coords.
Возвращает None если ни один источник не дал адрес. Endpoint
api/v1/geocode/reverse сам выкинет 404. НЕ даёт выйти HTTPStatusError
наверх — раньше Nominatim 403 → RetryError → FastAPI 500.
Snapped lat/lon — это центр matched здания (от provider'а), не echo
входных координат. Фронт по precision решает — двигать marker (exact/number)
или оставить на клике (street/locality).
db: если передан — cadastral lookup через gendesign_cad_buildings FDW (первый tier).
"""
# 1. Cadastral FDW primary (без внешнего API, возвращает жилой дом not POI)
if db is not None:
cad = await asyncio.to_thread(_cadastral_reverse_sync_full, db, lat, lon)
if cad is not None:
address, snap_lat, snap_lon = cad
return ReverseGeocodeResult(
address=address,
snapped_lat=snap_lat,
snapped_lon=snap_lon,
precision="number", # cadastral row = здание с house number
provider="cadastral",
)
# 2. Nominatim fallback (wrap to prevent 500 on ban/rate-limit)
try:
return await _nominatim_reverse(lat, lon)
except Exception:
logger.exception("nominatim reverse failed for (%.5f, %.5f)", lat, lon)
return None
def snap_precision_useful(precision: str) -> bool:
"""True если precision означает «нашли точное здание» — фронт двигает marker."""
return precision in _SNAP_PRECISIONS