All checks were successful
Deploy Trade-In / changes (push) Successful in 9s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 2m36s
Deploy Trade-In / build-backend (push) Successful in 59s
Deploy Trade-In / deploy (push) Successful in 1m26s
1592 lines
88 KiB
Python
1592 lines
88 KiB
Python
"""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.
|
||
}
|
||
)
|
||
# Значение для 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))
|
||
|
||
|
||
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
|
||
|
||
|
||
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=8))
|
||
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]
|
||
|
||
|
||
@retry(stop=stop_after_attempt(2), wait=wait_exponential(multiplier=1, min=1, max=4))
|
||
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:
|
||
"""Геокодинг с кэшем. 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)
|
||
|
||
|
||
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=8))
|
||
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
|