All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 8s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 51s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
Prod-cache verification (deep-review) found 3 false-positives: EKB addresses that literally contain "екатеринбург" but also happen to contain a homonym of another oblast city inside a neighborhood/posyolok name — ЖК "Заречный" and пос. Сысерть, both administratively inside Ekaterinburg, not the ZATO Zarechny / town Sysert they share a name with. These were gated off the EKB-only local tiers; if Nominatim were momentarily down, a real EKB address would return None — a latent regression not present before this PR. Add an explicit-city short-circuit at the top of _names_non_ekb_city: if the normalized address contains "екатеринбург", return False immediately (EKB tiers stay on). Cannot affect the true-positive (non-EKB) cases, which never contain "екатеринбург".
1546 lines
73 KiB
Python
1546 lines
73 KiB
Python
"""Geocoder service — address → lat/lon.
|
||
|
||
Стратегия:
|
||
- Cache lookup в `geocode_cache` (Postgres) — TTL 90 дней
|
||
- Cache miss → Yandex Geocoder (если есть key) → 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
|
||
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", "yandex", "cache"]
|
||
confidence: Literal["exact", "approximate", "locality"] = "approximate"
|
||
|
||
|
||
# ── 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/Yandex viewport для областного (не только ЕКБ) поиска — тот же
|
||
# формат что EKB_BBOX ниже, но покрывает всю область. viewbox — Nominatim
|
||
# (lon1,lat1,lon2,lat2, NW→SE); ll/spn — Yandex центр+охват (lon,lat).
|
||
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
|
||
# городов), покрывает основные региональные центры. Используется чтобы:
|
||
# (a) не навязывать "Екатеринбург, " в запрос, если пользователь уже указал
|
||
# другой город региона (геокодер forward/suggest prefix-логика);
|
||
# (b) как known-locality set в _yandex_reverse при перестановке "улица, город".
|
||
SVERDLOVSK_OBLAST_CITIES = frozenset(
|
||
{
|
||
"екатеринбург",
|
||
"нижний тагил",
|
||
"каменск-уральский",
|
||
"первоуральск",
|
||
"серов",
|
||
"новоуральск",
|
||
"ревда",
|
||
"полевской",
|
||
"асбест",
|
||
"богданович",
|
||
"ирбит",
|
||
"красноуфимск",
|
||
"верхняя пышма",
|
||
"берёзовский",
|
||
"березовский",
|
||
"заречный",
|
||
"качканар",
|
||
"краснотурьинск",
|
||
"североуральск",
|
||
"ивдель",
|
||
"тавда",
|
||
"туринск",
|
||
"сысерть",
|
||
"среднеуральск",
|
||
"дегтярск",
|
||
"верхняя салда",
|
||
"нижняя салда",
|
||
"невьянск",
|
||
"артёмовский",
|
||
"артемовский",
|
||
"камышлов",
|
||
}
|
||
)
|
||
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
|
||
|
||
|
||
# Города региона 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"
|
||
)
|
||
|
||
|
||
def _names_non_ekb_city(address: str) -> bool:
|
||
"""True если адрес явно называет город Свердловской области, ОТЛИЧНЫЙ от
|
||
Екатеринбурга (по границе слова, с исключением district-приставок «мкр/р-н
|
||
<город>»). Используется чтобы НЕ пускать EKB-only локальные тиры
|
||
(geoportal/cadastral) на не-ЕКБ адрес: улица+дом, совпадающие с ЕКБ-домом
|
||
(проспект Ленина 1 есть в обоих городах), иначе адрес снапается в ЕКБ (#11).
|
||
|
||
Явное "Екатеринбург" в адресе имеет приоритет над любым омонимом другого
|
||
города региона — иначе ЖК/посёлки с названием, совпадающим с другим
|
||
городом области (ЖК «Заречный», пос. «Сысерть» внутри ЕКБ), ложно
|
||
отсекали бы EKB-only тиры для реального ЕКБ-адреса.
|
||
"""
|
||
normalized = " ".join(address.lower().split())
|
||
# Явное "Екатеринбург" в адресе → это ЕКБ, даже если в тексте встречается
|
||
# омоним соседнего города (ЖК «Заречный», пос. Сысерть в составе МО «город
|
||
# Екатеринбург» и т.п. — реальные false-positive из прод-кэша).
|
||
if "екатеринбург" in 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
|
||
|
||
|
||
# ── Address normalisation ───────────────────────────────────────────────────
|
||
def normalize_address(address: str) -> str:
|
||
"""Нормализация для cache lookup: lowercase + trim + collapse whitespace.
|
||
|
||
« Ул. МАЛЫШЕВА, 30 » → «ул. малышева, 30»
|
||
"""
|
||
return " ".join(address.lower().strip().split())
|
||
|
||
|
||
# Согласные, которые часто пишут с одной буквой вместо двух (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) -> GeocodeResult | None:
|
||
"""OSM Nominatim — бесплатно, без ключа, 1 req/sec policy.
|
||
|
||
Бан-policy: User-Agent с email обязателен.
|
||
Tier 1: bounded область (region 66) на оригинальный адрес.
|
||
Tier 2: bounded область (region 66) на typo-варианты (Цвилинга → Цвиллинга).
|
||
"""
|
||
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/",
|
||
}
|
||
async with httpx.AsyncClient(timeout=10.0, headers=headers) as client:
|
||
# Tier 1: оригинал
|
||
item = await _nominatim_query(client, address)
|
||
|
||
# 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
|
||
item = await _nominatim_query(client, variant)
|
||
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,
|
||
)
|
||
|
||
|
||
def _yandex_region_ok(geo_object: dict) -> bool | None:
|
||
"""Кросс-чек региона по Yandex `AdministrativeAreaName`.
|
||
|
||
True/False если Yandex вернул поле и оно однозначно про/не про Свердловскую
|
||
область. None если поля нет / оно не строка — тогда accept-логика падает
|
||
обратно на bbox. Ловит Тюмень/Шадринск/Кунгур/Снежинск — они внутри
|
||
генерального OBLAST66_BBOX (специально щедрого), но их регион явно другой.
|
||
"""
|
||
try:
|
||
admin_area = (
|
||
geo_object.get("metaDataProperty", {})
|
||
.get("GeocoderMetaData", {})
|
||
.get("AddressDetails", {})
|
||
.get("Country", {})
|
||
.get("AdministrativeArea", {})
|
||
.get("AdministrativeAreaName")
|
||
)
|
||
except AttributeError:
|
||
return None
|
||
if not isinstance(admin_area, str) or not admin_area:
|
||
return None
|
||
return "свердловск" in admin_area.lower()
|
||
|
||
|
||
# ── Provider: Yandex Geocoder (требует key, лучшее покрытие РФ) ─────────────
|
||
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=8))
|
||
async def _yandex_lookup(address: str, api_key: str) -> GeocodeResult | None:
|
||
"""Yandex Geocoder — 25K req/day free для самопод, лучше РФ.
|
||
|
||
Docs: https://yandex.ru/dev/maps/geocoder/doc/desc/concepts/input_params.html
|
||
|
||
Запрашиваем с ll+spn (центр ЕКБ) для приоритизации местных результатов,
|
||
но БЕЗ rspn — чтобы fuzzy matching работал при опечатках.
|
||
"""
|
||
# Не навязываем "Екатеринбург, " если в адресе уже есть город/область региона 66
|
||
# (типичный кейс из suggest, либо явный запрос по другому городу области).
|
||
geocode_query = address if _has_oblast_marker(address.lower()) else f"Екатеринбург, {address}"
|
||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||
response = await client.get(
|
||
"https://geocode-maps.yandex.ru/1.x/",
|
||
params={
|
||
"apikey": api_key,
|
||
"geocode": geocode_query,
|
||
"format": "json",
|
||
"results": 5, # берем top-5, отфильтруем по ЕКБ bbox ниже
|
||
"lang": "ru_RU",
|
||
"ll": EKB_BBOX["ll"],
|
||
"spn": EKB_BBOX["spn"],
|
||
},
|
||
)
|
||
response.raise_for_status()
|
||
data = response.json()
|
||
|
||
members = data.get("response", {}).get("GeoObjectCollection", {}).get("featureMember", [])
|
||
if not members:
|
||
return None
|
||
|
||
# Фильтруем top-5 по bbox области (region 66) — игнорируем Челябинск/Уфу/Москву
|
||
# при опечатке, но принимаем легитимные Нижний Тагил/Серов/etc. Two-pass
|
||
# tie-break: tight-ЕКБ приоритетнее (byte-identical для ЕКБ-запросов, даже
|
||
# если Yandex ранжировал его не первым), иначе первый oblast-bbox кандидат.
|
||
# Region cross-check (AdministrativeAreaName) отсекает кандидатов ЯВНО из
|
||
# другого региона (Тюмень и т.п. попадают в генеральный bbox координатно).
|
||
best = None
|
||
oblast_fallback = None
|
||
for m in members:
|
||
obj = m.get("GeoObject", {})
|
||
try:
|
||
lon_str, lat_str = obj["Point"]["pos"].split()
|
||
lat_f, lon_f = float(lat_str), float(lon_str)
|
||
except Exception:
|
||
continue
|
||
if _yandex_region_ok(obj) is False:
|
||
continue # регион явно не Свердловская область — не рассматриваем
|
||
if is_within_ekb_bbox(lat_f, lon_f):
|
||
best = obj
|
||
break
|
||
if oblast_fallback is None and is_within_oblast66_bbox(lat_f, lon_f):
|
||
oblast_fallback = obj
|
||
|
||
if best is None:
|
||
best = oblast_fallback
|
||
|
||
if best is None:
|
||
# Никто из top-5 не попал ни в tight, ни в oblast bbox (или все явно
|
||
# отсеяны по региону) → берём первый «как есть», НО только если его
|
||
# регион не опровергнут явно — иначе весь смысл region cross-check
|
||
# теряется на garbage-query, где top-1 — гарантированно не тот регион
|
||
# (например, Тюмень).
|
||
first_obj = members[0]["GeoObject"]
|
||
if _yandex_region_ok(first_obj) is not False:
|
||
best = first_obj
|
||
|
||
if best is None:
|
||
return None
|
||
|
||
lon_str, lat_str = best["Point"]["pos"].split()
|
||
precision_raw = (
|
||
best.get("metaDataProperty", {}).get("GeocoderMetaData", {}).get("precision", "other")
|
||
)
|
||
confidence_map = {
|
||
"exact": "exact",
|
||
"number": "exact",
|
||
"near": "approximate",
|
||
"range": "approximate",
|
||
"street": "approximate",
|
||
}
|
||
return GeocodeResult(
|
||
lat=float(lat_str),
|
||
lon=float(lon_str),
|
||
full_address=best.get("metaDataProperty", {})
|
||
.get("GeocoderMetaData", {})
|
||
.get("text", address),
|
||
provider="yandex",
|
||
confidence=confidence_map.get(precision_raw, "approximate"),
|
||
)
|
||
|
||
|
||
# ── Suggest (автокомплит) ───────────────────────────────────────────────────
|
||
# ЕКБ bounding box (приблизительно): юг 56.65, запад 60.40, север 56.95, восток 60.85
|
||
# Это ЕКБ fast-path (Yandex rspn=1 bounded tier + soft-bias ll/spn). Для областного
|
||
# охвата (region 66) — см. OBLAST66_VIEWBOX выше.
|
||
EKB_BBOX = {
|
||
"viewbox": "60.40,56.95,60.85,56.65", # Nominatim format: lon1,lat1,lon2,lat2 (NW,SE)
|
||
"ll": "60.605,56.838", # Yandex center (lon,lat)
|
||
"spn": "0.45,0.30", # Yandex span (lon,lat)
|
||
}
|
||
|
||
|
||
@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). У Yandex/Nominatim/cadastral-тиров и у
|
||
# street/locality-кандидатов остаётся None (нет стабильного house-fias).
|
||
fias_id: str | None = None
|
||
|
||
|
||
def _parse_yandex_members(members: list[dict]) -> list[GeocodeSuggestion]:
|
||
"""Yandex geocode_members → list[GeocodeSuggestion]. Чистим описание от мусора."""
|
||
out: list[GeocodeSuggestion] = []
|
||
for m in members:
|
||
obj = m.get("GeoObject", {})
|
||
try:
|
||
lon_str, lat_str = obj["Point"]["pos"].split()
|
||
meta = obj.get("metaDataProperty", {}).get("GeocoderMetaData", {})
|
||
kind = meta.get("kind", "other")
|
||
full = meta.get("text", obj.get("name", ""))
|
||
name = obj.get("name", full)
|
||
desc = obj.get("description", "")
|
||
desc_parts = [
|
||
p.strip()
|
||
for p in desc.split(",")
|
||
if p.strip() and p.strip() not in {"Россия", "Свердловская область"}
|
||
]
|
||
label = name if not desc_parts else f"{name} · {', '.join(desc_parts)}"
|
||
out.append(
|
||
GeocodeSuggestion(
|
||
label=label,
|
||
full_address=full,
|
||
lat=float(lat_str),
|
||
lon=float(lon_str),
|
||
kind=kind,
|
||
)
|
||
)
|
||
except Exception:
|
||
continue
|
||
return out
|
||
|
||
|
||
# ── DaData suggest wrapper ──────────────────────────────────────────────────
|
||
# Маппинг DaData kind → GeocodeSuggestion.kind (внутри geocoder используется
|
||
# 'locality' вместо 'city' — consistent с Yandex/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='Свердловская область', hard-filter внутри
|
||
`suggest_addresses`), а не один город ЕКБ — иначе Нижний Тагил/Серов/etc
|
||
никогда не появились бы в подсказках.
|
||
"""
|
||
raw = await dadata.suggest_addresses(
|
||
query, limit=limit, city=None, region=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 _yandex_geocode_request(
|
||
client: httpx.AsyncClient, api_key: str, query: str, limit: int, bounded: bool
|
||
) -> list[dict]:
|
||
"""Single Yandex Geocoder request — bounded=True → строго в ЕКБ через rspn=1."""
|
||
params: dict[str, str] = {
|
||
"apikey": api_key,
|
||
"geocode": query,
|
||
"format": "json",
|
||
"results": str(limit),
|
||
"lang": "ru_RU",
|
||
"ll": EKB_BBOX["ll"],
|
||
"spn": EKB_BBOX["spn"],
|
||
}
|
||
if bounded:
|
||
params["rspn"] = "1"
|
||
response = await client.get("https://geocode-maps.yandex.ru/1.x/", params=params)
|
||
response.raise_for_status()
|
||
data = response.json()
|
||
return data.get("response", {}).get("GeoObjectCollection", {}).get("featureMember", [])
|
||
|
||
|
||
@retry(stop=stop_after_attempt(2), wait=wait_exponential(multiplier=1, min=1, max=4))
|
||
async def _yandex_suggest(query: str, api_key: str, limit: int = 8) -> list[GeocodeSuggestion]:
|
||
"""Yandex Geocoder с авто-fallback на typo-tolerant режим.
|
||
|
||
Tier 1: bounded ЕКБ (rspn=1) — быстрый путь для основного (ЕКБ) трафика.
|
||
Tier 2: bounded ЕКБ на typo-variants (удвоение согласных).
|
||
Tier 3: без rspn — fuzzy по всей стране, фильтр результатов по bbox области
|
||
(region 66) — ловит легитимные Нижний Тагил/Серов/etc, которые Tier 1/2
|
||
(bounded строго ЕКБ) структурно вернуть не могут.
|
||
"""
|
||
prefixed_query = query if _has_oblast_marker(query.lower()) else f"Екатеринбург, {query}"
|
||
async with httpx.AsyncClient(timeout=8.0) as client:
|
||
# Tier 1: strict bounded на оригинал (ЕКБ fast path)
|
||
members = await _yandex_geocode_request(
|
||
client,
|
||
api_key,
|
||
prefixed_query,
|
||
limit,
|
||
bounded=True,
|
||
)
|
||
results = _parse_yandex_members(members)
|
||
if results:
|
||
return results
|
||
|
||
# Tier 2: bounded на typo-варианты (тот же ЕКБ fast path)
|
||
for variant in _typo_variants(query, limit=4):
|
||
variant_query = (
|
||
variant if _has_oblast_marker(variant.lower()) else f"Екатеринбург, {variant}"
|
||
)
|
||
members = await _yandex_geocode_request(
|
||
client,
|
||
api_key,
|
||
variant_query,
|
||
limit,
|
||
bounded=True,
|
||
)
|
||
results = _parse_yandex_members(members)
|
||
if results:
|
||
return results
|
||
|
||
# Tier 3: без rspn — даём fuzzy сделать своё дело, фильтр по bbox области
|
||
members = await _yandex_geocode_request(
|
||
client,
|
||
api_key,
|
||
prefixed_query,
|
||
limit,
|
||
bounded=False,
|
||
)
|
||
results = _parse_yandex_members(members)
|
||
in_oblast = [r for r in results if is_within_oblast66_bbox(r.lat, r.lon)]
|
||
return in_oblast
|
||
|
||
|
||
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 []
|
||
|
||
|
||
@retry(stop=stop_after_attempt(2), wait=wait_exponential(multiplier=1, min=1, max=4))
|
||
async def _nominatim_suggest(query: str, limit: int = 8) -> list[GeocodeSuggestion]:
|
||
"""Nominatim в режиме suggest. С typo-fallback (для случаев когда Yandex недоступен).
|
||
|
||
Суффикс ", Екатеринбург" навязывается ТОЛЬКО если в запросе ещё нет города/области
|
||
региона 66 — иначе не режем явные запросы по другим городам области.
|
||
"""
|
||
headers = {
|
||
"User-Agent": f"TradeInMVP/0.1 (contact: {settings.contact_email})",
|
||
"Accept": "application/json",
|
||
"Accept-Language": "ru,en;q=0.8",
|
||
}
|
||
suffixed_query = query if _has_oblast_marker(query.lower()) else f"{query}, Екатеринбург"
|
||
async with httpx.AsyncClient(timeout=8.0, headers=headers) as client:
|
||
# Tier 1: оригинальный query
|
||
data = await _nominatim_query_multi(client, suffixed_query, 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_query = (
|
||
variant if _has_oblast_marker(variant.lower()) else f"{variant}, Екатеринбург"
|
||
)
|
||
data = await _nominatim_query_multi(client, variant_query, 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)
|
||
|
||
|
||
def _cadastral_house_match(db: Session, street: str, house: str) -> GeocodeSuggestion | None:
|
||
"""Anchored cadastral match: ILIKE по улице + regex-anchor на дом-маркер.
|
||
|
||
SQL validated на проде (11/16 hits, 0 false positives). Anchor на
|
||
«д./дом/строение» убивает ложный матч номера внутри «(1-83)»-диапазона.
|
||
|
||
`street` идёт ТОЛЬКО в bound-param ILIKE (безопасно). Для regex берём
|
||
только ЦИФРЫ дома (regex-safe) — конкатенируем bound-param внутри SQL.
|
||
Литеру (если есть) используем лишь для tie-break сортировки.
|
||
"""
|
||
house_digits_m = re.match(r"\d+", house)
|
||
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|пос[. ]|посёлок|поселок|северка|шабровский'
|
||
|| '| км|снт|гараж|коллективный сад)'
|
||
)
|
||
ORDER BY
|
||
(CASE WHEN CAST(:house_full AS text) ~ '[а-яё]'
|
||
AND readable_address ~* (CAST(:house_full AS text) || '(\D|$)')
|
||
THEN 0 ELSE 1 END),
|
||
length(readable_address) ASC
|
||
LIMIT 1
|
||
"""),
|
||
{"street": street, "house_digits": house_digits, "house_full": house},
|
||
).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) -> list[GeocodeSuggestion]:
|
||
"""Автокомплит адресов в Свердловской области (region 66; ЕКБ — основной трафик,
|
||
остаётся быстрым fast-path). Cadastral FDW → DaData → Yandex → Nominatim → [].
|
||
|
||
db: если передан — cadastral lookup через gendesign_cad_buildings (первый tier).
|
||
DaData /suggest (PR Q2) — token-only, 10k/день, заменяет Yandex который
|
||
заблокирован (1k/день demo limit исчерпан).
|
||
Без кэша (дешёво, провайдеры толерируют автокомплит-запросы).
|
||
"""
|
||
if not query or len(query.strip()) < 2:
|
||
return []
|
||
|
||
# Tier 1: cadastral FDW (если db доступна) — самый быстрый, без внешних запросов
|
||
if db is not None:
|
||
# 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 для РФ адресов. Заменил Yandex как primary external provider
|
||
# после того как demo-key Yandex (1k/день) был исчерпан.
|
||
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 yandex")
|
||
|
||
# Tier 3: Yandex (legacy — оставляем как fallback, если key есть)
|
||
if settings.yandex_geocoder_api_key:
|
||
try:
|
||
results = await _yandex_suggest(query, settings.yandex_geocoder_api_key, limit)
|
||
if results:
|
||
return results
|
||
except Exception:
|
||
logger.exception("yandex suggest failed, falling back to nominatim")
|
||
|
||
# Tier 4: Nominatim (последний fallback — OSM, без ключа)
|
||
try:
|
||
return await _nominatim_suggest(query, limit)
|
||
except Exception:
|
||
logger.exception("nominatim suggest failed")
|
||
return []
|
||
|
||
|
||
# ── Public API ───────────────────────────────────────────────────────────────
|
||
async def geocode(address: str, db: Session) -> GeocodeResult | None:
|
||
"""Геокодинг с кэшем. Cadastral FDW → Yandex → Nominatim → None.
|
||
|
||
Args:
|
||
address: пользовательский ввод (может быть грязным — нормализуем).
|
||
db: сессия Postgres для cache lookup/write и cadastral FDW lookup.
|
||
|
||
Returns:
|
||
GeocodeResult или None если ни один провайдер не отвечает.
|
||
"""
|
||
if not address or len(address.strip()) < 3:
|
||
return None
|
||
|
||
addr_norm = normalize_address(address)
|
||
|
||
# 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 cached
|
||
|
||
# 2. Локальные источники по street+house (без внешнего API).
|
||
parsed = _parse_street_house(address.strip())
|
||
|
||
# #11: EKB-only локальные тиры (geoportal/cadastral) не должны отвечать на
|
||
# адрес другого города области — иначе улица+дом, коллизящие с ЕКБ-домом
|
||
# (напр. "проспект Ленина 1" есть и в Нижнем Тагиле, и в ЕКБ), снапаются в
|
||
# ЕКБ. Пропускаем сразу к oblast-aware внешним провайдерам ниже (3/4).
|
||
use_local_ekb = not _names_non_ekb_city(address)
|
||
|
||
# 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",
|
||
)
|
||
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",
|
||
)
|
||
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",
|
||
)
|
||
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. Yandex (если есть key) с typo-fallback
|
||
if settings.yandex_geocoder_api_key:
|
||
try:
|
||
result = await _yandex_lookup(address, settings.yandex_geocoder_api_key)
|
||
# Если результат вне области (region 66) — пробуем typo-варианты
|
||
in_oblast = result is not None and is_within_oblast66_bbox(result.lat, result.lon)
|
||
if result is not None and in_oblast:
|
||
await asyncio.to_thread(_cache_put, db, addr_norm, result)
|
||
logger.info("geocode yandex: %s → (%.5f, %.5f)", addr_norm, result.lat, result.lon)
|
||
return result
|
||
# Tier 2: typo-variants
|
||
for variant in _typo_variants(address, limit=4):
|
||
try:
|
||
result = await _yandex_lookup(variant, settings.yandex_geocoder_api_key)
|
||
except Exception:
|
||
continue
|
||
if result is None:
|
||
continue
|
||
if is_within_oblast66_bbox(result.lat, result.lon):
|
||
await asyncio.to_thread(_cache_put, db, addr_norm, result)
|
||
logger.info(
|
||
"geocode yandex typo-fixed: %s → %s → (%.5f, %.5f)",
|
||
addr_norm,
|
||
variant,
|
||
result.lat,
|
||
result.lon,
|
||
)
|
||
return result
|
||
except Exception:
|
||
logger.exception("yandex geocoder failed, falling back to nominatim")
|
||
|
||
# 4. Nominatim fallback
|
||
try:
|
||
result = await _nominatim_lookup(address)
|
||
if result is not None:
|
||
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 — точный матч на здание (Yandex)
|
||
# - number — найден дом с номером (то что нам надо для квартирного оценщика)
|
||
# - cadastral — Cadastral FDW row (то же по точности что Yandex "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` — yandex-style: `exact`/`number`/`street`/`range`/`near`/
|
||
`locality`/`other`/`cadastral`. Используется фронтом чтобы
|
||
решить — двигать marker (exact/number) или нет.
|
||
- `provider` — кто дал результат (`yandex`/`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["yandex", "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 _yandex_reverse(lat: float, lon: float, api_key: str) -> ReverseGeocodeResult | None:
|
||
"""Yandex Geocoder /reverse — возвращает snapped Point.pos здания + precision.
|
||
|
||
Docs: https://yandex.ru/dev/maps/geocoder/doc/desc/concepts/input_params.html
|
||
Параметр `geocode` принимает `lon,lat` (важно — обратный порядок!).
|
||
"""
|
||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||
response = await client.get(
|
||
"https://geocode-maps.yandex.ru/1.x/",
|
||
params={
|
||
"apikey": api_key,
|
||
"geocode": f"{lon},{lat}",
|
||
"format": "json",
|
||
"results": "1",
|
||
"kind": "house", # просим именно здание (house), не улицу
|
||
"lang": "ru_RU",
|
||
},
|
||
)
|
||
response.raise_for_status()
|
||
data = response.json()
|
||
|
||
members = data.get("response", {}).get("GeoObjectCollection", {}).get("featureMember", [])
|
||
if not members:
|
||
return None
|
||
obj = members[0].get("GeoObject", {})
|
||
try:
|
||
lon_str, lat_str = obj["Point"]["pos"].split()
|
||
snapped_lat = float(lat_str)
|
||
snapped_lon = float(lon_str)
|
||
except (KeyError, ValueError):
|
||
return None
|
||
meta = obj.get("metaDataProperty", {}).get("GeocoderMetaData", {})
|
||
precision = str(meta.get("precision", "other"))
|
||
address_text = str(meta.get("text") or obj.get("name") or "")
|
||
# Yandex address text начинается с «Россия, Свердловская область, …» — режем prefix,
|
||
# оставляем «улица, дом, город» для consistency с Nominatim/cadastral.
|
||
if address_text:
|
||
# «Россия, Свердловская область, Екатеринбург, улица Малышева, 51»
|
||
# → «улица Малышева, 51, Екатеринбург» (drop country/oblast, swap city/street)
|
||
parts = [p.strip() for p in address_text.split(",") if p.strip()]
|
||
filtered = [
|
||
p
|
||
for p in parts
|
||
if p not in {"Россия", "Свердловская область"} and not p.startswith("городской округ")
|
||
]
|
||
# Найдём locality (Екатеринбург / Нижний Тагил / Берёзовский / …) и переставим
|
||
# в конец — любой город региона 66, не только ЕКБ (case-insensitive match).
|
||
locality = None
|
||
rest: list[str] = []
|
||
for p in filtered:
|
||
if locality is None and p.lower() in SVERDLOVSK_OBLAST_CITIES:
|
||
locality = p
|
||
else:
|
||
rest.append(p)
|
||
if locality and rest:
|
||
address_text = ", ".join([*rest, locality])
|
||
else:
|
||
address_text = ", ".join(filtered)
|
||
|
||
if not address_text:
|
||
return None
|
||
return ReverseGeocodeResult(
|
||
address=address_text,
|
||
snapped_lat=snapped_lat,
|
||
snapped_lon=snapped_lon,
|
||
precision=precision,
|
||
provider="yandex",
|
||
)
|
||
|
||
|
||
@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 → Yandex (если key) → 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. Yandex — основной источник snap'а (его Point.pos = центр здания)
|
||
if settings.yandex_geocoder_api_key:
|
||
try:
|
||
result = await _yandex_reverse(lat, lon, settings.yandex_geocoder_api_key)
|
||
if result is not None:
|
||
return result
|
||
except Exception:
|
||
logger.exception("yandex reverse failed for (%.5f, %.5f)", lat, lon)
|
||
|
||
# 3. 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
|