All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / changes (pull_request) Successful in 10s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m24s
`asyncio.to_thread` отменить нельзя: по истечении бюджета (`_with_budget` = `asyncio.wait_for`, у геокодера 12 с) снимается только ожидание со стороны loop'а — поток продолжает работать с ТОЙ ЖЕ `Session`, что и весь запрос. Вызывающий тем временем идёт дальше: следующий источник, `_fetch_anchor_comps`, `_persist_estimate_and_commit`. Два потока в одной `Session` дают «another operation is in progress» / InvalidRequestError на СЛЕДУЮЩЕМ шаге. У источников эту ошибку глушит `except` вокруг вызова, у персиста оценки не глушит никто — 500 и потерянная оценка клиента. `app/core/db.py: run_db_thread` — ТОЛЬКО защита от сироты: `ensure_future` + `shield`, на отмене дождаться потока (`asyncio.wait`), прочитать `step.exception()` (иначе asyncio печатает «Task exception was never retrieved» без контекста) и пробросить отмену. Commit/rollback туда НЕ вынесены: посреди геокодинга commit зафиксировал бы частичное состояние оценки. `estimator._db_step` переписан поверх и добавляет свои commit/rollback сам — его поведение не меняется, гейт tests/test_3408_db_step_cancel_orphan.py остаётся зелёным. Заменено 34 вызова, работающих по сессии запроса: 12 в geocoder.py (кэш-чтение и записи, геопортал, кадастр, houses, reverse, suggest), 19 в estimator.py (в т.ч. `_backfill_house_fias`, `_save_yandex_history_items`, `_fetch_anchor_comps`, `_price_from_inputs` с db-резолверами, персист оценки, `_fetch_price_trend`, `_is_premium_building`), 2 в api/v1/geocode.py, 1 в api/v1/privacy_admin.py. Не тронуты вызовы со СВОЕЙ сессией: `user_events.schedule_event` (внутри `record_event` свой `SessionLocal`) и `sber_index` (сессия задачи планировщика, отменять её некому). Гейт по значению — tests/test_3449_geocoder_cancel_orphan.py: отмена по бюджету во время шага БД геокодера, следом ГОЛЫЙ `to_thread(db.execute, ...)` (образец персиста); проверяется, что он не вошёл в сессию, пока сирота ещё в ней. На исходном коде тест краснеет: conflicts == 1. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2352 lines
140 KiB
Python
2352 lines
140 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
|
||
import time
|
||
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.core.db import run_db_thread
|
||
from app.services import dadata
|
||
from app.services.regions import REGIONS as _ALL_REGIONS
|
||
from app.services.regions import Region, is_within_bbox
|
||
|
||
_REGION_66 = _ALL_REGIONS[66]
|
||
|
||
# #3051: маркер `address.state` Nominatim по региону, для region cross-check
|
||
# в `_nominatim_region_ok` (см. использование в `_nominatim_query`). Регионы
|
||
# без записи здесь получают `marker=None` → cross-check пропускается
|
||
# (fallback на bbox-only, прежнее поведение).
|
||
_REGION_STATE_MARKERS: dict[int, str] = {66: "свердловск", 77: "москва"}
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# ── Общий ограничитель темпа обращений к Nominatim (#2953) ──────────────────
|
||
#
|
||
# Политика Nominatim — 1 req/sec. Раньше её изображали три разрозненных
|
||
# `asyncio.sleep(1.0)`, и ни один не давал ограничения на самом деле:
|
||
#
|
||
# geocode() — сон стоял ВНУТРИ `if result is not None`, то есть
|
||
# только после УСПЕХА; после неудачи паузы не было;
|
||
# _nominatim_lookup() — сон МЕЖДУ typo-вариантами, но не перед tier-1;
|
||
# цикл бэкфилла — своей паузы не имеет вовсе.
|
||
#
|
||
# Для неразрешимого адреса получалось 5 запросов за 4 секунды, и сразу
|
||
# следующий адрес без паузы. А неразрешимые адреса — не редкость: на
|
||
# 20.08.2026 из 1471 активного объявления без координат 1424 лежали в
|
||
# семидневном backoff, то есть череда неудач случается КАЖДЫЙ прогон
|
||
# бэкфилла. Отсюда 194 события «429 Too many requests» за неделю.
|
||
#
|
||
# Здесь пауза берётся ПЕРЕД каждым запросом и одна на все точки вызова, а не
|
||
# после успеха и не на каждую по отдельности.
|
||
#
|
||
# ponytail: ограничитель внутрипроцессный. Если бэкфилл и пользовательские
|
||
# запросы разъедут по разным процессам, их темпы снова сложатся — тогда
|
||
# понадобится общий счётчик (Redis). Пока доминирующий источник один
|
||
# (бэкфилл, сотни запросов за прогон), внутрипроцессного достаточно.
|
||
_NOMINATIM_MIN_INTERVAL_SEC = 1.0
|
||
_nominatim_gate = asyncio.Lock()
|
||
_nominatim_last_call_at = 0.0
|
||
|
||
|
||
async def _nominatim_throttle() -> None:
|
||
"""Держит паузу ≥ _NOMINATIM_MIN_INTERVAL_SEC между обращениями к Nominatim."""
|
||
global _nominatim_last_call_at
|
||
async with _nominatim_gate:
|
||
overdue = _NOMINATIM_MIN_INTERVAL_SEC - (time.monotonic() - _nominatim_last_call_at)
|
||
if overdue > 0:
|
||
await asyncio.sleep(overdue)
|
||
_nominatim_last_call_at = time.monotonic()
|
||
|
||
|
||
# ── 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
|
||
# #2626: True если результат дал ПОСЛЕДНИЙ локальный тир — fallback на `houses`
|
||
# (скрейпленные листинги, см. `_local_houses_match`) — а не Nominatim/geoportal/
|
||
# cadastral. Срабатывает, когда в тексте адреса опечатка/сокращение улицы
|
||
# («Онуфриева» вместо канонического «Начдива Онуфриева» в ГАР) или отсутствует
|
||
# корпус («49» вместо реального «49к1») — houses-фолбэк нашёл ОДНОЗНАЧНЫЙ дом по
|
||
# нормализованному совпадению. Честный сигнал вызывающему коду «адрес уточнён
|
||
# автоматически», НЕ эвристика на корректность — см. `geocode()`/`_local_houses_match`.
|
||
# Houses-фолбэк НЕ пишет свой результат в `geocode_cache` (менее надёжный
|
||
# источник координат, чем geoportal/cadastral/Nominatim — #2626 review R2 #4),
|
||
# поэтому этот сигнал переживает КАЖДЫЙ повторный запрос того же сырого
|
||
# адреса. `geocode_cache` вообще не хранит этот флаг (схему не трогаем) —
|
||
# если бы houses-хит когда-нибудь попал в кэш, на cache-hit `address_refined`
|
||
# вернулся бы `False` (та же судьба у `city_ambiguous` при cache-hit — см.
|
||
# `_geocode_resolve`, восстанавливается `replace()` из текущего вызова, а не
|
||
# из кэша).
|
||
address_refined: bool = False
|
||
|
||
|
||
# ── EKB bounding boxes ───────────────────────────────────────────────────────
|
||
# Два 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).
|
||
# #3051: значения живут в реестре регионов (app.services.regions, регион 66) —
|
||
# имена остаются здесь ради существующих импортёров и читаемости вызовов.
|
||
EKB_BBOX_TIGHT = _REGION_66.bbox_tight
|
||
EKB_BBOX_WIDE = _REGION_66.bbox_wide
|
||
|
||
|
||
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 = _REGION_66.bbox_region # #3051: значение — в реестре регионов
|
||
|
||
# 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 = _REGION_66.cities # #3051: список — в реестре регионов
|
||
|
||
|
||
def known_city_hint(value: str | None, region_code: int = 66) -> str | None:
|
||
"""`value` как city_hint, если это узнаваемое имя города `REGIONS[region_code]`, иначе None.
|
||
|
||
#3051: `region_code` (дефолт 66) — параметризация под трек «Москва»: словарь
|
||
городов берётся из `REGIONS[region_code].cities`, а не жёстко из
|
||
`SVERDLOVSK_OBLAST_CITIES`. Для `region_code=66` (дефолт, все существующие
|
||
вызовы без аргумента) — byte-identical прежнему поведению: `REGIONS[66].cities
|
||
is SVERDLOVSK_OBLAST_CITIES` (тот же frozenset-объект, см. модульный уровень).
|
||
Неизвестный `region_code` → ValueError (явная ошибка, не молчаливый None).
|
||
|
||
Для callers, которые берут город из КОЛОНКИ БД и передают его в `geocode()`
|
||
(#2603): `deals.city` — росреестровое поле, заполнено на 100%, но в хвосте
|
||
распределения лежит мусор («Бессонова», «Бердюгина», «Билейский
|
||
рыбопитомник» — улицы/урочища, попавшие в поле города). Мусорный хинт хуже
|
||
отсутствия хинта: любой не-ЕКБ `city_hint` жёстко закрывает EKB-локальные
|
||
тиры (`_ekb_local_tiers_allowed`) И подставляется префиксом в запрос
|
||
провайдеру (`_resolve_city_for_geocode`) — «Бессонова, Бессонова 10»
|
||
провайдер не резолвит вовсе.
|
||
|
||
Словарь — `SVERDLOVSK_OBLAST_CITIES`, тот же, на котором стоят городские
|
||
гейты самого геокодера. Отдельного списка сознательно НЕ заводим: город,
|
||
отсутствующий в этом наборе, и так обрабатывается геокодером как незнакомый
|
||
(`_names_non_ekb_city` его не увидит, `estimator._resolve_target_city` не
|
||
резолвит) — т.е. новый город области в любом случае добавляется СЮДА, и
|
||
гейт хинта не создаёт новой связности.
|
||
|
||
Цена решения (осознанная): легитимный, но не перечисленный населённый пункт
|
||
(«Реж», «Арамиль», сёла/посёлки) хинта не получит и вернётся к поведению «по
|
||
тексту адреса» — то же, что было до прошивки хинта, без регрессии.
|
||
|
||
Пользовательский ввод (`/geocode/lookup`, `/geocode/suggest`,
|
||
`TradeInEstimateInput.city_hint`) сюда НЕ заворачиваем: там город назвал
|
||
человек, и молча его игнорировать нельзя — для произвольной строки
|
||
fail-closed отрабатывает `_ekb_local_tiers_allowed` (#2580/#2589).
|
||
"""
|
||
if not value:
|
||
return None
|
||
if region_code == 66:
|
||
cities = SVERDLOVSK_OBLAST_CITIES
|
||
else:
|
||
try:
|
||
cities = _ALL_REGIONS[region_code].cities
|
||
except KeyError as exc:
|
||
raise ValueError(f"unknown region_code={region_code!r}") from exc
|
||
return value if " ".join(value.lower().split()) in cities else None
|
||
|
||
|
||
# Значение для DaData-констрейнта `locations: [{"region": ...}]`.
|
||
# ВАЖНО: DaData хранит имя региона БЕЗ типа — `region="Свердловская"`,
|
||
# `region_type="обл"` (тип лежит в отдельных полях `region_type` /
|
||
# `region_with_type`). `locations` сравнивает именно с `region`, поэтому
|
||
# «Свердловская область» не совпадает НИ С ЧЕМ и hard-фильтр молча схлопывал
|
||
# выдачу в 0 подсказок (замер на проде: «Свердловская область» → 0 хитов,
|
||
# «Свердловская» → 5 хитов, первый — искомый «д 13б» с fias_id).
|
||
# Тип региона сюда дописывать нельзя — см. `test_dadata_region_constant_*`.
|
||
SVERDLOVSK_OBLAST_REGION = "Свердловская"
|
||
|
||
# Word/phrase-boundary regex — НЕ substring — чтобы «Серова 27» не матчил город
|
||
# «Серов», «Ирбитская 5» — «Ирбит», «Асбестовский пер.» — «Асбест», «Невьянский
|
||
# пер.» — «Невьянск», «Богдановича» — «Богданович» (все реальные EKB-адреса,
|
||
# substring-матч ложно ронял "Екатеринбург, "-префикс на них → неверный город
|
||
# → неверная цена). Python `\b` для str-паттернов юникод-осведомлён (Cyrillic
|
||
# входит в `\w`), поэтому «серова» корректно НЕ матчит `\bсеров\b` (после
|
||
# «серов» идёт «а» — тоже \w, границы нет).
|
||
_OBLAST_CITY_RE = re.compile(
|
||
r"\b(?:" + "|".join(re.escape(city) for city in SVERDLOVSK_OBLAST_CITIES) + r")\b"
|
||
)
|
||
_OBLAST_MARKER_RE = re.compile(r"\bсвердловск\w*\b")
|
||
# Приставки, после которых токен обозначает РАЙОН/микрорайон города, а не сам
|
||
# город региона: «мкр Заречный» — микрорайон ВНУТРИ другого города (не ЗАТО
|
||
# Заречный). Без этого исключения точное совпадение слова "заречный" всё равно
|
||
# ложно сработало бы (это не substring-баг, а реальная омонимия имени).
|
||
_DISTRICT_PREFIXES = frozenset({"мкр", "мкр.", "микрорайон", "р-н", "р-он", "район", "жк"})
|
||
|
||
|
||
# region_code → скомпилированный regex городов региона (word-boundary), кэш по
|
||
# коду. 66 — literal reuse `_OBLAST_CITY_RE` (тот же объект, byte-identical),
|
||
# остальные регионы строятся из `REGIONS[region_code].cities` при первом
|
||
# обращении (#3051).
|
||
_REGION_CITY_RE: dict[int, re.Pattern[str]] = {66: _OBLAST_CITY_RE}
|
||
|
||
|
||
def _region_city_re(region_code: int) -> re.Pattern[str]:
|
||
cached = _REGION_CITY_RE.get(region_code)
|
||
if cached is not None:
|
||
return cached
|
||
cities = _ALL_REGIONS[region_code].cities
|
||
compiled = re.compile(r"\b(?:" + "|".join(re.escape(c) for c in cities) + r")\b")
|
||
_REGION_CITY_RE[region_code] = compiled
|
||
return compiled
|
||
|
||
|
||
def _has_oblast_marker(text_lower: str, region_code: int = 66) -> bool:
|
||
"""True если текст уже содержит упоминание области/города `region_code`.
|
||
|
||
Используется чтобы НЕ навязывать "Екатеринбург, "/"Москва, " в запрос,
|
||
когда адрес уже привязан к другому городу/области — иначе получим двойной
|
||
город ("Екатеринбург, Нижний Тагил, Ленина 10" / "Москва, Москва, Тверская
|
||
1", #3051 п. б) и провайдер вернёт мусор/пусто.
|
||
|
||
Матчинг — по границе слова/фразы (`_region_city_re`), НЕ substring — и с
|
||
исключением "мкр/микрорайон/р-н <город>" (район ВНУТРИ другого города).
|
||
`region_code=66` (дефолт) дополнительно матчит "свердловск*" — областной
|
||
маркер без города; у прочих регионов такого обобщённого маркера нет,
|
||
город региона уже покрывает случай (для 77 — "москва" в `region.cities`).
|
||
"""
|
||
normalized = " ".join(text_lower.split())
|
||
if region_code == 66 and _OBLAST_MARKER_RE.search(normalized):
|
||
return True
|
||
for m in _region_city_re(region_code).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, region_code: int = 66
|
||
) -> tuple[str | None, bool]:
|
||
"""Определяет, какой город подставлять в запрос внешнему провайдеру
|
||
(Nominatim), когда сам текст адреса города не называет.
|
||
|
||
Приоритет:
|
||
1. Адрес уже содержит маркер города/области `region_code` (`_has_oblast_marker`)
|
||
→ город уже указан пользователем в тексте адреса, ничего подставлять не
|
||
нужно. Возвращает (None, True).
|
||
2. `city_hint` передан вызывающим кодом (например, фронт знает выбранный
|
||
город из предыдущего шага UI) → подставляем его. Возвращает (city, True).
|
||
3. Ни то, ни другое → раньше (#2576) здесь молча подставлялся "Екатеринбург"
|
||
— для жителей других городов области это давало уверенно неверную цену
|
||
(«Ленина, 1» в Нижнем Тагиле снапалось на екатеринбургскую улицу Ленина,
|
||
обе улицы называются одинаково). Теперь НЕ подставляем никакой город —
|
||
провайдер ищет по region-viewbox/bbox (см. `OBLAST66_VIEWBOX`,
|
||
`_region_viewbox`), без привязки к конкретному городу. Возвращает
|
||
(None, False) — второй элемент False сигнализирует, что город
|
||
пользователь НЕ указывал (источник `GeocodeResult.city_ambiguous`).
|
||
|
||
`region_code` (дефолт 66, #3051) — byte-identical прежнему поведению для
|
||
всех вызовов без аргумента.
|
||
|
||
Returns:
|
||
(city_or_none, city_specified_by_user).
|
||
"""
|
||
if _has_oblast_marker(address.lower(), region_code):
|
||
return None, True
|
||
hint = (city_hint or "").strip()
|
||
if hint:
|
||
return hint, True
|
||
return None, False
|
||
|
||
|
||
# Города региона 66 КРОМЕ Екатеринбурга — используется чтобы отсечь EKB-only
|
||
# локальные тиры (geoportal/cadastral, см. `geocode()`) от адреса другого
|
||
# города области. re.escape на элементах SVERDLOVSK_OBLAST_CITIES-{ekb}.
|
||
_NON_EKB_OBLAST_CITIES = SVERDLOVSK_OBLAST_CITIES - {"екатеринбург"}
|
||
_NON_EKB_CITY_RE = re.compile(
|
||
r"\b(?:" + "|".join(re.escape(c) for c in _NON_EKB_OBLAST_CITIES) + r")\b"
|
||
)
|
||
# Word-boundary, НЕ substring — «екатеринбургское шоссе» (реальная улица в
|
||
# городах-спутниках, напр. Первоуральске) не должно матчить как «Екатеринбург».
|
||
_EKATERINBURG_RE = re.compile(r"\bекатеринбург\b")
|
||
|
||
|
||
def _names_non_ekb_city(address: str) -> bool:
|
||
"""True если адрес явно называет город Свердловской области, ОТЛИЧНЫЙ от
|
||
Екатеринбурга (по границе слова, с исключением district-приставок «мкр/р-н
|
||
<город>»). Используется чтобы НЕ пускать EKB-only локальные тиры
|
||
(geoportal/cadastral) на не-ЕКБ адрес: улица+дом, совпадающие с ЕКБ-домом
|
||
(проспект Ленина 1 есть в обоих городах), иначе адрес снапается в ЕКБ (#11).
|
||
|
||
Явное "Екатеринбург" в адресе имеет приоритет над любым омонимом другого
|
||
города региона — иначе ЖК/посёлки с названием, совпадающим с другим
|
||
городом области (ЖК «Заречный», пос. «Сысерть» внутри ЕКБ), ложно
|
||
отсекали бы EKB-only тиры для реального ЕКБ-адреса.
|
||
"""
|
||
normalized = " ".join(address.lower().split())
|
||
# Явное "Екатеринбург" (отдельным словом — см. `_EKATERINBURG_RE`) в адресе
|
||
# → это ЕКБ, даже если в тексте встречается омоним соседнего города (ЖК
|
||
# «Заречный», пос. Сысерть в составе МО «город Екатеринбург» и т.п. —
|
||
# реальные false-positive из прод-кэша). Word-boundary же не даёт слову
|
||
# «Екатеринбургское» (шоссе в городах-спутниках) ложно сработать как город.
|
||
if _EKATERINBURG_RE.search(normalized):
|
||
return False
|
||
for m in _NON_EKB_CITY_RE.finditer(normalized):
|
||
prefix_words = normalized[: m.start()].split()
|
||
if prefix_words and prefix_words[-1] in _DISTRICT_PREFIXES:
|
||
continue # «мкр Заречный» — район внутри другого города, не ЗАТО Заречный
|
||
return True
|
||
return False
|
||
|
||
|
||
# ── Fail-closed EKB-local-tiers gate (#2582) ─────────────────────────────────
|
||
# `_names_non_ekb_city` выше ловит только 37 ПЕРЕЧИСЛЕННЫХ городов области —
|
||
# любой другой топоним (другой регион РФ целиком, ИЛИ областной город вне
|
||
# списка: Реж/Арамиль/Рефтинский/Лёвиха) молча проходил как «не назван», и
|
||
# EKB-only локальные тиры (geoportal/cad_buildings) снапали улицу+дом в ЕКБ по
|
||
# коллизии (см. живые случаи в geocode_cache: «республика крым, ялта, ...» →
|
||
# координаты ЕКБ, «челябинская обл., трёхгорный, ...» → координаты ЕКБ).
|
||
#
|
||
# Вместо перечисления (сама причина бага — список никогда не покроет все
|
||
# города РФ) используем общий структурный признак «в адресе назван
|
||
# город/регион»: маркер-слово, которое НИКОГДА не относится к улице/дому
|
||
# («обл.», «край», «республика», «г.», «пос.»).
|
||
#
|
||
# Два разных класса маркеров — разная форма regex:
|
||
# 1. Маркеры БЕЗ обязательной точки после (обл/область/край/республика/респ/
|
||
# г/пос/посёлок/поселок) — сохраняют старую форму `\bслово\.?\b`. Точка
|
||
# опциональна и есть трейлинг `\b`, который для этих слов срабатывает и
|
||
# без точки (следующий символ — пробел/запятая, не-словесный).
|
||
# 2. Маркеры-ОДНОБУКВЕННЫЕ сокращения, где точка ОБЯЗАТЕЛЬНА для отличия от
|
||
# других смыслов той же буквы («с» — типографский мусор/что угодно, «д» —
|
||
# дом-маркер `_HOUSE_MARKER`) — «с.», «д.», «дер.», «пгт.», «ст.». Тут
|
||
# хвостовой `\b` В ПРИНЦИПЕ не может сработать после точки перед пробелом
|
||
# (оба не-словесные — на стыке `.` и ` ` нет границы \w/не-\w) — было
|
||
# смерто-рождённой формой, вылавливавшей 0 из 104 прод-адресов «с. <село>»
|
||
# (deep-review PR #2589, H1). Вместо `\b` в конце — lookahead на кириллицу
|
||
# сразу после точки (с опциональными пробелами): `\.\s*(?=[а-яё])`.
|
||
# Это и различает «д. 5» (дом-маркер, номер, НЕ топоним — после точки
|
||
# цифра, lookahead не матчит) от «д. Кашино» (деревня — после точки буква,
|
||
# матчит) БЕЗ явного исключения "д." из списка: раньше "д." исключали
|
||
# целиком, потому что не было способа отличить дом от деревни — lookahead
|
||
# даёт этот способ, поэтому "д."/"дер." теперь можно включить безопасно.
|
||
_LOCALITY_MARKER_RE = re.compile(
|
||
r"\b(?:обл\.?|область|край|республика|респ\.?|г|пос\.?|посёлок|поселок)\b"
|
||
r"|\b(?:с|д|дер|пгт|ст)\.\s*(?=[а-яё])"
|
||
)
|
||
|
||
|
||
def _names_unrecognized_locality(address: str) -> bool:
|
||
"""True если адрес называет топоним, но это не Екатеринбург и не город из
|
||
известного списка `SVERDLOVSK_OBLAST_CITIES` (#2582).
|
||
|
||
Отличает «город не назван» (bare «Ленина 1» — так приходит основной
|
||
трафик формы оценки; geoportal резолвит его как ЕКБ и это оправданный
|
||
дефолт) от «назван НЕЗНАКОМЫЙ город» (Ялта/Трёхгорный/Лёвиха, «с. Кашино»,
|
||
«д. Родина» — структурный маркер топонима есть, но это не ЕКБ и не
|
||
известный город региона 66) — только во втором случае EKB-only локальные
|
||
тиры нельзя пускать.
|
||
|
||
Известная дыра (сознательно, не «баг» — закрывать architectural-решением
|
||
отдельно, не перечислением городов): голый «Реж, Ленина 5» / «Арамиль,
|
||
Мира 1» БЕЗ структурного маркера («обл.»/«г.»/«с.» и т.п.) и без города
|
||
из списка `SVERDLOVSK_OBLAST_CITIES` не ловится — падает в «город не
|
||
назван» и уходит в ЕКБ-дефолт. Все живые прод-подмены (geocode_cache)
|
||
были с маркером/региональным префиксом — см. тесты `test_ekb_local_tiers_allowed`
|
||
(изолирующий кейс на голое «реж, ленина 5» зафиксирован как известная дыра).
|
||
"""
|
||
normalized = " ".join(address.lower().split())
|
||
if _EKATERINBURG_RE.search(normalized):
|
||
return False # явный ЕКБ — приоритет, см. _names_non_ekb_city
|
||
return bool(_LOCALITY_MARKER_RE.search(normalized))
|
||
|
||
|
||
# ── Постфактум-инвариант подмены города (#2590) ──────────────────────────────
|
||
# Гейты выше (#2582/#2589) стоят НА ВХОДЕ и решают, пускать ли ЕКБ-only тиры.
|
||
# Внешние провайдеры ими не покрыты: «реж, ленина» уходит в Nominatim/Yandex, и
|
||
# тот, не найдя Режа, отдаёт улицу Ленина в Екатеринбурге. Отличить на входе
|
||
# «Реж» (город) от «Малышева» (улица) без списка городов нельзя — оба «слово до
|
||
# запятой». ПОСЛЕ ответа можно: провайдер сам пишет, какой населённый пункт он
|
||
# использовал, и если названный топоним туда не дожил — топоним подменён.
|
||
#
|
||
# Инвариант (#2590): назван топоним ≠ Екатеринбург + его нет в ответе провайдера
|
||
# + ответ лежит внутри ЕКБ ⇒ результат недостоверен. Ни одного имени города в
|
||
# коде — только уровни РФ-адреса (страна → регион → район → НП → улица → дом) и
|
||
# сам целевой город.
|
||
_ADDRESS_SEGMENT_RE = re.compile(r"[,;·]")
|
||
# Страна: сегмент выше уровня НП. Единственная константа-топоним помимо целевого
|
||
# города — продукт РФ-only, новых значений у неё не появится (в отличие от
|
||
# списка городов области, ради ухода от которого всё и делается).
|
||
_COUNTRY_RE = re.compile(r"\b(?:росси[яи]|russia)\b")
|
||
# Уровень «улица/дом»: дойдя до него, НП уже был бы назван (порядок РФ-адреса
|
||
# big→small). Дальше идти нельзя — иначе второй уличный сегмент («малышева,
|
||
# мопра» — перекрёсток) читается как топоним и ложно отбраковывается.
|
||
_STREET_LEVEL_RE = re.compile(
|
||
r"\b(?:ул|улица|пер|переулок|пр|пр-кт|пркт|проспект|б-р|бульвар|ш|шоссе|наб|набережная"
|
||
r"|пл|площадь|проезд|тракт|аллея|тупик|туп|линия|кв-л|квартал|стр|строение|дом|корп"
|
||
r"|корпус|лит|литера|снт|сад|гск)\b"
|
||
)
|
||
# Уровни ВЫШЕ и НИЖЕ населённого пункта — пропускаем и идём дальше по сегментам:
|
||
# «свердловская обл., г.о. рефтинский» (регион → НП), «мкр-н широкая речка, ул.
|
||
# …» (район ВНУТРИ города — его провайдер в ответе обычно не повторяет).
|
||
_REGION_LEVEL_RE = re.compile(r"\b(?:обл\.?|область|края|край|республика|респ\.?|ао)\b")
|
||
_DISTRICT_LEVEL_RE = re.compile(r"\b(?:р-н|р-он|район|мкр|мкр-н|микрорайон|жк|жилой)\b")
|
||
# Слова-ТИПЫ НП (не имя): «пос. Кедровка» → имя «кедровка». Тип не сравнивается
|
||
# с ответом — провайдер пишет свой («посёлок» vs «пос.»), имя же обязано дожить.
|
||
_LOCALITY_TYPE_WORDS = frozenset(
|
||
{
|
||
"поселок",
|
||
"пос",
|
||
"село",
|
||
"деревня",
|
||
"дер",
|
||
"город",
|
||
"гор",
|
||
"округ",
|
||
"муниципальный",
|
||
"городской",
|
||
"сельское",
|
||
"поселение",
|
||
"тер",
|
||
"территория",
|
||
"станция",
|
||
"пгт",
|
||
"рп",
|
||
}
|
||
)
|
||
_WORD_RE = re.compile(r"[а-я][а-я-]*")
|
||
|
||
|
||
def _fold(value: str) -> str:
|
||
"""lower + ё→е + схлопывание пробелов — общий канон для сравнения топонимов."""
|
||
return " ".join(value.lower().replace("ё", "е").split())
|
||
|
||
|
||
def _claimed_locality(address: str) -> str | None:
|
||
"""Имя населённого пункта, названное в тексте адреса, или None.
|
||
|
||
Структурно, БЕЗ перечисления городов: идём по сегментам в порядке РФ-адреса
|
||
(страна → регион → район → НП → улица → дом), пропускаем уровни выше/ниже
|
||
НП, останавливаемся на уровне улицы/дома. Первый оставшийся сегмент — имя НП.
|
||
|
||
None означает «НП не назван» — это основной трафик формы («Малышева 30»), и
|
||
для него инвариант не применяется вовсе.
|
||
"""
|
||
for segment in _ADDRESS_SEGMENT_RE.split(_fold(address)):
|
||
segment = segment.strip()
|
||
if not segment:
|
||
continue
|
||
if any(ch.isdigit() for ch in segment) or _STREET_LEVEL_RE.search(segment):
|
||
return None # улица/дом: будь НП назван, он шёл бы раньше
|
||
if (
|
||
_COUNTRY_RE.search(segment)
|
||
or _REGION_LEVEL_RE.search(segment)
|
||
or _DISTRICT_LEVEL_RE.search(segment)
|
||
):
|
||
continue
|
||
name = " ".join(
|
||
w for w in _WORD_RE.findall(segment) if w not in _LOCALITY_TYPE_WORDS and len(w) >= 3
|
||
)
|
||
if name:
|
||
return name
|
||
return None
|
||
|
||
|
||
def _city_substituted(address: str, result: GeocodeResult) -> bool:
|
||
"""True если провайдер подменил названный в адресе НП Екатеринбургом (#2590).
|
||
|
||
Три условия вместе:
|
||
1. в адресе назван НП и это не Екатеринбург (`_claimed_locality`);
|
||
2. этого имени НЕТ в адресе, который вернул провайдер — то есть топоним не
|
||
пережил геокодинг;
|
||
3. результат лежит внутри ЕКБ: и по координатам (`EKB_BBOX_TIGHT`), и по
|
||
собственному ответу провайдера — он называет Екатеринбург либо не
|
||
называет НП вовсе (ЕКБ-only локальные реестры отдают «Улица, дом»;
|
||
тогда «внутри ЕКБ» подтверждают координаты).
|
||
|
||
Условие 3 и разводит подмену с посёлками в городской черте. «пос. Кедровка,
|
||
Советская ул., 5» → ответ «Екатеринбург, Советская улица, 5» — имя не дожило,
|
||
и это ПРАВДА подмена: настоящая Кедровка в 20 км от улицы Советской. А
|
||
корректный ответ по посёлку («Кедровка, Екатеринбург, …» — Nominatim и Yandex
|
||
пишут НП всегда, когда действительно его нашли) имя сохраняет и через фильтр
|
||
не проходит. Проверяется не география посёлка, а факт «топоним потерян».
|
||
|
||
Известный потолок: НП, чьё имя совпало с уличным токеном ответа («Ачит» ⊂
|
||
«М-12 Ачит-Екатеринбург», «Лесной» ⊂ «Лесной переулок»), считается дожившим —
|
||
пропуск, не ложная отбраковка. Обратный потолок: жилрайон ЕКБ, названный без
|
||
приставки («пионерский, советская»), понижается до `locality` — честная
|
||
деградация, координаты не теряются.
|
||
"""
|
||
claimed = _claimed_locality(address)
|
||
if claimed is None or _EKATERINBURG_RE.search(claimed):
|
||
return False
|
||
answer = _fold(result.full_address or "")
|
||
if any(word in answer for word in claimed.split()):
|
||
return False # топоним дожил до ответа — провайдер искал там, где просили
|
||
if not is_within_ekb_bbox(result.lat, result.lon):
|
||
return False
|
||
answer_locality = _claimed_locality(answer)
|
||
return answer_locality is None or bool(_EKATERINBURG_RE.search(answer_locality))
|
||
|
||
|
||
def _ekb_local_tiers_allowed(address: str, city_hint: str | None = None) -> bool:
|
||
"""Fail-closed гейт локальных ЕКБ-тиров geocoder (`geocode()`/`suggest()`, #2582).
|
||
|
||
ЕКБ-only локальные реестры (geoportal/cad_buildings) используются ТОЛЬКО
|
||
когда Екатеринбург подтверждён явно (текстом адреса или `city_hint`), или
|
||
нигде не назван никакой город/регион. Гейт закрывается, если сработал
|
||
ЛЮБОЙ из трёх независимых сигналов (OR, не приоритетная цепочка):
|
||
|
||
- известный город региона 66 в ТЕКСТЕ адреса (`_names_non_ekb_city`, без
|
||
изменений, #11);
|
||
- `city_hint` ЗАДАН и НЕ является Екатеринбургом (`hint_blocks` ниже) —
|
||
#2580 (C1) + #2589 (deep-review M2): `city_hint` — явный СТРУКТУРНЫЙ
|
||
вход от caller'а (не свободный текст адреса), поэтому для него
|
||
fail-closed форма ОБРАТНАЯ по сравнению с текстом адреса: «не
|
||
Екатеринбург → закрыто», а не «есть в списке 37 → закрыто». Список
|
||
37 годился бы, только если единственный риск — известные города; но
|
||
`city_hint` принимает произвольную строку без валидации (в т.ч. через
|
||
API напрямую, минуя 6-городный selector UI) — «Реж»/любой седьмой
|
||
город, добавленный в реестр, до сих пор не в списке 37 → список-based
|
||
проверка держала гейт открытым, geoportal отдавал точный ЕКБ-дом, а
|
||
`city_ambiguous=False` (хинт ведь БЫЛ) делало это уверенно-неверным —
|
||
худший случай (без hint был бы честный `city_ambiguous=True`);
|
||
- незнакомый топоним в ТЕКСТЕ адреса — структурный маркер
|
||
(`_names_unrecognized_locality`, #2582: «обл./край/республика/г./пос.»
|
||
и однобуквенные «с./д./дер./пгт./ст.» с lookahead на кириллицу после
|
||
точки) — покрывает города вне списка 37 С маркером (Лёвиха, «с.
|
||
Новоалексеевское») и ДРУГИЕ РЕГИОНЫ РФ целиком (Ялта, Трёхгорный), не
|
||
требуя их перечислять. Голый город без маркера и вне списка (Реж без
|
||
«обл.») — известная дыра, см. `_names_unrecognized_locality`.
|
||
|
||
До #2582 решение по третьему пункту принималось от противного: «нет в
|
||
списке 37 городов → считаем Екатеринбургом» — молча ломало любой адрес
|
||
вне списка (см. docstring `_names_unrecognized_locality`).
|
||
|
||
Note: т.к. это OR трёх независимых сигналов (не приоритетная цепочка),
|
||
`city_hint`, противоречащий явному "Екатеринбург" в тексте адреса
|
||
(`_names_non_ekb_city(address)` для такого текста вернёт False и не
|
||
закроет гейт сам по себе — Екатеринбург в тексте не мешает), тем не менее
|
||
ЗАКРОЕТ гейт через `hint_blocks` — тот же trade-off, что уже был одобрен
|
||
в #2580 (`use_local_ekb`), сюда не переписываем. Нормальный кейс — caller
|
||
не передаёт противоречащие address и city_hint одновременно.
|
||
"""
|
||
hint = " ".join((city_hint or "").lower().split())
|
||
hint_blocks = bool(hint) and not _EKATERINBURG_RE.search(hint)
|
||
return not (
|
||
_names_non_ekb_city(address) or hint_blocks or _names_unrecognized_locality(address)
|
||
)
|
||
|
||
|
||
# ── Address normalisation ───────────────────────────────────────────────────
|
||
def normalize_address(address: str) -> str:
|
||
"""Нормализация для cache lookup: lowercase + trim + collapse whitespace.
|
||
|
||
« Ул. МАЛЫШЕВА, 30 » → «ул. малышева, 30»
|
||
"""
|
||
return " ".join(address.lower().strip().split())
|
||
|
||
|
||
def _cache_key(address_norm: str, city_hint: str | None) -> str:
|
||
"""Ключ `geocode_cache.address_normalized` — адрес, дополненный городом,
|
||
если он известен вызывающему коду.
|
||
|
||
#2576: раньше ключ был просто нормализованный адрес — одинаковый для
|
||
«Ленина, 1» независимо от того, кто спрашивает (ЕКБ или Нижний Тагил).
|
||
Т.к. геокодер раньше молча предполагал ЕКБ, оба города писали/читали ОДНУ
|
||
и ту же строку кэша → взаимная порча (первый запрос «застолбил» город для
|
||
второго). С `city_hint` разные города для одного текста адреса больше не
|
||
делят один ключ.
|
||
|
||
БЕЗ `city_hint` формат ключа не меняется (backward-compatible с уже
|
||
накопленным кэшем) — коллизия между городами для запросов без hint'а
|
||
остаётся возможной (структурно неизбежно, пока вызывающий код не начнёт
|
||
передавать city_hint повсеместно), но `city_ambiguous` на результате
|
||
честно сигнализирует об этом вызывающему.
|
||
"""
|
||
city_norm = " ".join((city_hint or "").lower().strip().split())
|
||
if not city_norm:
|
||
return address_norm
|
||
return f"{address_norm}|city={city_norm}"
|
||
|
||
|
||
# Согласные, которые часто пишут с одной буквой вместо двух (RU typos).
|
||
_DOUBLE_CONSONANTS = "лнмссккттпп"
|
||
|
||
|
||
def _typo_variants(query: str, limit: int = 6) -> list[str]:
|
||
"""Генерация вариантов с удвоением согласных — для случая когда пользователь
|
||
написал «Цвилинга» вместо «Цвиллинга», «Толстова» вместо «Толстого» итд.
|
||
|
||
Каждая позиция где есть одинокая согласная — кандидат на удвоение.
|
||
Возвращаем до `limit` вариантов в порядке вероятности (ближе к началу слова → выше).
|
||
"""
|
||
if len(query) < 3:
|
||
return []
|
||
variants: list[str] = []
|
||
chars = list(query)
|
||
for i in range(1, len(chars) - 1):
|
||
c = chars[i].lower()
|
||
if c not in _DOUBLE_CONSONANTS:
|
||
continue
|
||
prev_c = chars[i - 1].lower()
|
||
next_c = chars[i + 1].lower()
|
||
# Удваиваем только если соседи — гласные/'ь'/'ъ' (характерно для русских типо)
|
||
if prev_c in "аеёиоуыэюяьъ" and next_c in "аеёиоуыэюяьъ":
|
||
variant = query[: i + 1] + chars[i] + query[i + 1 :]
|
||
if variant != query and variant not in variants:
|
||
variants.append(variant)
|
||
if len(variants) >= limit:
|
||
break
|
||
return variants
|
||
|
||
|
||
# ── Cache ────────────────────────────────────────────────────────────────────
|
||
def _cache_get(db: Session, address_norm: str) -> GeocodeResult | None:
|
||
row = db.execute(
|
||
text(
|
||
"""
|
||
SELECT lat, lon, full_address, provider, confidence
|
||
FROM geocode_cache
|
||
WHERE address_normalized = :addr
|
||
AND expires_at > NOW()
|
||
"""
|
||
),
|
||
{"addr": address_norm},
|
||
).fetchone()
|
||
if row is None:
|
||
return None
|
||
return GeocodeResult(
|
||
lat=row.lat,
|
||
lon=row.lon,
|
||
full_address=row.full_address,
|
||
provider="cache",
|
||
confidence=row.confidence or "approximate",
|
||
)
|
||
|
||
|
||
def _cache_put(db: Session, address_norm: str, result: GeocodeResult) -> None:
|
||
db.execute(
|
||
text(
|
||
"""
|
||
INSERT INTO geocode_cache
|
||
(address_normalized, lat, lon, full_address, provider, confidence)
|
||
VALUES (:addr, :lat, :lon, :full, :provider, :conf)
|
||
ON CONFLICT (address_normalized) DO UPDATE
|
||
SET lat = EXCLUDED.lat,
|
||
lon = EXCLUDED.lon,
|
||
full_address = EXCLUDED.full_address,
|
||
provider = EXCLUDED.provider,
|
||
confidence = EXCLUDED.confidence,
|
||
created_at = NOW(),
|
||
expires_at = NOW() + interval '90 days'
|
||
"""
|
||
),
|
||
{
|
||
"addr": address_norm,
|
||
"lat": result.lat,
|
||
"lon": result.lon,
|
||
"full": result.full_address,
|
||
"provider": result.provider,
|
||
"conf": result.confidence,
|
||
},
|
||
)
|
||
db.commit()
|
||
|
||
|
||
# ── Provider: Nominatim (OSM, без ключа) ────────────────────────────────────
|
||
def _nominatim_region_ok(item: dict, region_code: int = 66) -> bool | None:
|
||
"""Кросс-чек региона по Nominatim `address.state` (доступно т.к. addressdetails=1).
|
||
|
||
True/False если state однозначно про/не про регион `region_code`. None если
|
||
поле отсутствует/не строка, ИЛИ регион не имеет записи в `_REGION_STATE_MARKERS`
|
||
— тогда accept-логика падает обратно на bbox.
|
||
Ловит Тюмень/Шадринск/Кунгур/Снежинск — они внутри генерального OBLAST66_BBOX
|
||
(специально щедрого), но их state явно другой регион. `region_code=66`
|
||
(дефолт) — byte-identical прежнему поведению (`"свердловск" in state.lower()`).
|
||
"""
|
||
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
|
||
marker = _REGION_STATE_MARKERS.get(region_code)
|
||
if marker is None:
|
||
return None
|
||
return marker in state.lower()
|
||
|
||
|
||
def _region_viewbox(region: Region) -> str:
|
||
"""Nominatim `viewbox` (lon_min,lat_max,lon_max,lat_min) из `region.bbox_region`.
|
||
|
||
Для region 66 см. `OBLAST66_VIEWBOX["viewbox"]` — литеральная константа
|
||
(byte-identical), эта функция для 66 не вызывается.
|
||
"""
|
||
lat_min, lat_max, lon_min, lon_max = region.bbox_region
|
||
return f"{lon_min},{lat_max},{lon_max},{lat_min}"
|
||
|
||
|
||
def _viewbox_for_region(region_code: int) -> str:
|
||
"""Nominatim `viewbox` по коду региона — ЕДИНАЯ точка для всех тиров.
|
||
|
||
`region_code=66` → литеральная `OBLAST66_VIEWBOX["viewbox"]`: значение
|
||
историческое, из bbox не выводится, поэтому byte-identical прежнему
|
||
поведению. Прочие регионы — рамка из реестра (`_region_viewbox`).
|
||
"""
|
||
if region_code == 66:
|
||
return OBLAST66_VIEWBOX["viewbox"]
|
||
return _region_viewbox(_ALL_REGIONS[region_code])
|
||
|
||
|
||
def _region_default_city(region_code: int) -> str:
|
||
"""Главный город региона — текстовый суффикс запроса, когда город не назван.
|
||
|
||
`region_code=66` → литеральный "Екатеринбург" (byte-identical dual-query
|
||
#2580/C2). Прочие — `canonical_city` реестра, иначе `city_token` с заглавной.
|
||
"""
|
||
if region_code == 66:
|
||
return "Екатеринбург"
|
||
region = _ALL_REGIONS[region_code]
|
||
return region.canonical_city or region.city_token.capitalize()
|
||
|
||
|
||
async def _nominatim_query(
|
||
client: httpx.AsyncClient, address: str, region_code: int = 66
|
||
) -> dict | None:
|
||
"""Single Nominatim search. Возвращает лучший item или None.
|
||
|
||
ВАЖНО: фильтруем результаты по bbox региона `region_code` прямо тут, чтобы
|
||
при опечатках не возвращать Пермский край / Челябинск — но не резать
|
||
легитимные Нижний Тагил / Серов и т.д. (генеральный bbox всего региона).
|
||
|
||
Two-pass tie-break: среди кандидатов предпочитаем того, кто попадает в TIGHT
|
||
bbox региона (byte-identical для region_code=66, даже если Nominatim
|
||
ранжировал его не первым) — иначе первый кандидат внутри генерального bbox
|
||
региона. Плюс region cross-check (`address.state`) — отсекает кандидатов
|
||
ЯВНО из другого региона (Тюмень и т.п.), даже если координаты попали в
|
||
генеральный bbox. `region_code=66` (дефолт) — byte-identical прежнему
|
||
поведению (те же bbox-значения и та же viewbox-строка).
|
||
"""
|
||
region = _ALL_REGIONS[region_code]
|
||
viewbox = _viewbox_for_region(region_code)
|
||
await _nominatim_throttle()
|
||
response = await client.get(
|
||
"https://nominatim.openstreetmap.org/search",
|
||
params={
|
||
"q": address,
|
||
"format": "json",
|
||
"limit": "3",
|
||
"countrycodes": "ru",
|
||
"addressdetails": "1",
|
||
"viewbox": viewbox,
|
||
"bounded": "1", # строго в пределах региона
|
||
},
|
||
)
|
||
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, region_code) is False:
|
||
continue # регион явно не тот, что запрошен — не рассматриваем
|
||
if is_within_bbox(lat_f, lon_f, region.bbox_tight):
|
||
return item # tight-bbox приоритетнее — тот же результат, что и раньше
|
||
if oblast_fallback is None and is_within_bbox(lat_f, lon_f, region.bbox_region):
|
||
oblast_fallback = item
|
||
return oblast_fallback
|
||
|
||
|
||
# reraise=True (GlitchTip-noise fix): без него tenacity на исчерпанных ретраях
|
||
# бросает СВОЙ tenacity.RetryError, чей str() тащит repr() последнего Future
|
||
# (`<Future at 0x...>` — адрес объекта в памяти, разный на КАЖДЫЙ вызов). GlitchTip
|
||
# группирует по этому нестабильному тексту → одна и та же причина (Nominatim
|
||
# недоступен/rate-limit) плодила отдельный issue на каждое исчерпание ретраев
|
||
# (2 462 issue из 7 461 в трекере). reraise=True пробрасывает РЕАЛЬНОЕ исключение
|
||
# (httpx.HTTPStatusError/TimeoutException) — стабильный ТИП+стек. НО httpx.HTTPStatusError
|
||
# сам несёт нестабильный ТЕКСТ (str() содержит полный request URL, включая query
|
||
# string с адресом — `for url '...search?q=<адрес>&...'`) — group-стабильность на
|
||
# ЭТОМ пути держит `_HTTPX_ERROR_URL_QUERY_RE` в app/observability/sentry_scrub.py
|
||
# (`scrub_pii_event`, часть before_send-композиции обоих entrypoint), которая режет
|
||
# query string из httpx-style "for url '...'" сообщений (GlitchTip-noise review
|
||
# round 2, claim #1 — reraise=True сам по себе НЕ закрывает per-address explosion).
|
||
# Отдельно — `stabilize_retry_error_fingerprint` (та же sentry_scrub.py) на случай
|
||
# если голый tenacity.RetryError (не httpx-исключение) всплывёт откуда-то ещё
|
||
# (belt-and-suspenders для retry-кода без reraise=True, напр. scraper_kit).
|
||
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=8), reraise=True)
|
||
async def _nominatim_lookup(
|
||
address: str, city_hint: str | None = None, region_code: int = 66
|
||
) -> GeocodeResult | None:
|
||
"""OSM Nominatim — бесплатно, без ключа, 1 req/sec policy.
|
||
|
||
Бан-policy: User-Agent с email обязателен.
|
||
Tier 1: bounded регион `region_code` на оригинальный адрес.
|
||
Tier 2: bounded регион `region_code` на typo-варианты (Цвилинга → Цвиллинга).
|
||
|
||
#2580 (C): city_hint, если известен, подставляется в текст запроса — без
|
||
него `_nominatim_query` полагается ТОЛЬКО на region-bbox фильтр + tie-break
|
||
(предпочитает tight bbox региона), который для одноимённых улиц ВНУТРИ
|
||
региона (напр. "Ленина" — и в Екатеринбурге, и в с. Свердловское) не
|
||
различает город. Эмпирически подтверждено: "Ленина 1" без города →
|
||
случайное село внутри области; "Нижний Тагил, Ленина 1" → корректно
|
||
резолвится. Nominatim — единственный живой внешний провайдер (#2593:
|
||
Yandex Geocoder удалён) — city_hint должен реально влиять на его результат,
|
||
не только на кэш-ключ. `region_code=66` (дефолт, #3051) — byte-identical
|
||
прежнему поведению для всех вызовов без аргумента.
|
||
"""
|
||
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, region_code)
|
||
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, region_code)
|
||
|
||
# Tier 2: typo-variants
|
||
if item is None:
|
||
for variant in _typo_variants(address, limit=4):
|
||
variant_city, _ = _resolve_city_for_geocode(variant, city_hint, region_code)
|
||
variant_query = f"{variant_city}, {variant}" if variant_city else variant
|
||
item = await _nominatim_query(client, variant_query, region_code)
|
||
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 (автокомплит) ───────────────────────────────────────────────────
|
||
|
||
|
||
# Части адреса приходят из реестров УЖЕ склеенными — и склеенными грязно:
|
||
# ЕГРН `readable_address` печатает маркер без значения и оставляет лишний
|
||
# пробел («г Екатеринбург, ул Фролова , д. 29, корп.»). Чиним склейку, а не
|
||
# конкретный случай: один проход по частям — схлопнуть пробелы, выбросить
|
||
# пустые и маркер-без-номера (корп./стр./лит./кв. и пр.), склеить обратно.
|
||
_RE_ADDR_WS = re.compile(r"\s+")
|
||
_RE_ADDR_BARE_MARKER = re.compile(
|
||
r"^(?:д|дом|корп|корпус|к|стр|строение|соор|сооружение|лит|литера|литер"
|
||
r"|кв|квартира|оф|офис|пом|помещение|вл|владение|уч|участок)\.?$",
|
||
re.IGNORECASE,
|
||
)
|
||
|
||
|
||
def tidy_address(s: str) -> str:
|
||
"""«г Екатеринбург, ул Фролова , д. 29, корп.» → «г Екатеринбург, ул Фролова, д. 29».
|
||
|
||
Часть без значения ВЫПАДАЕТ, а не печатается пустым маркером.
|
||
"""
|
||
parts = []
|
||
for raw in s.split(","):
|
||
part = _RE_ADDR_WS.sub(" ", raw).strip()
|
||
if not part or _RE_ADDR_BARE_MARKER.match(part):
|
||
continue
|
||
parts.append(part)
|
||
return ", ".join(parts)
|
||
|
||
|
||
@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
|
||
|
||
def __post_init__(self) -> None:
|
||
# Единственная точка, через которую проходят ВСЕ тиры (cadastral/
|
||
# geoportal/houses/DaData/Nominatim) — чистим здесь, а не у каждого.
|
||
object.__setattr__(self, "label", tidy_address(self.label))
|
||
object.__setattr__(self, "full_address", tidy_address(self.full_address))
|
||
|
||
|
||
# ── DaData suggest wrapper ──────────────────────────────────────────────────
|
||
# Маппинг DaData kind → GeocodeSuggestion.kind (внутри geocoder используется
|
||
# 'locality' вместо 'city' — consistent с Nominatim-веткой).
|
||
_DADATA_KIND_MAP = {"house": "house", "street": "street", "city": "locality"}
|
||
|
||
# region_code → значение поля DaData `region` (БЕЗ типа: «Свердловская», а не
|
||
# «Свердловская область» — тип лежит отдельно в `region_type`). Реестр регионов
|
||
# хранит человекочитаемое имя С типом, для hard-констрейнта оно не годится,
|
||
# поэтому отдельная карта — по образцу `_REGION_STATE_MARKERS` для Nominatim.
|
||
_DADATA_REGION_NAMES: dict[int, str] = {66: SVERDLOVSK_OBLAST_REGION, 77: "Москва"}
|
||
|
||
|
||
def _dadata_region_name(region_code: int) -> str:
|
||
"""Имя региона для hard-констрейнта DaData. Неизвестный код → ValueError."""
|
||
try:
|
||
return _DADATA_REGION_NAMES[region_code]
|
||
except KeyError as exc:
|
||
raise ValueError(f"dadata suggest: unknown region_code={region_code!r}") from exc
|
||
|
||
|
||
async def _dadata_suggest(
|
||
query: str, limit: int = 8, region_code: int = 66
|
||
) -> list[GeocodeSuggestion]:
|
||
"""Обёртка над `dadata.suggest_addresses` — конвертит в GeocodeSuggestion.
|
||
|
||
Дроп candidate'ов без координат (DaData возвращает их для широких categories
|
||
типа город/район, для autocomplete с привязкой к карте они бесполезны).
|
||
|
||
Label собирается из DaData `value` (короткая форма «ул Малышева, д 30»).
|
||
Constraint — вся область (region=`SVERDLOVSK_OBLAST_REGION`, hard-filter
|
||
внутри `suggest_addresses`), а не один город ЕКБ — иначе Нижний Тагил/
|
||
Серов/etc никогда не появились бы в подсказках.
|
||
"""
|
||
region_name = _dadata_region_name(region_code)
|
||
raw = await dadata.suggest_addresses(query, limit=limit, city=None, region=region_name)
|
||
if not raw:
|
||
# Region-констрейнт — hard-filter: неверное значение схлопывает выдачу в
|
||
# 0 БЕЗ ошибки (так и жил баг «Свердловская область» → 0 подсказок).
|
||
# Отдельный warning, чтобы следующая такая регрессия была видна в логах,
|
||
# а не выглядела как «DaData ничего не знает про этот адрес».
|
||
logger.warning(
|
||
"dadata suggest: 0 кандидатов для %r при region=%r — "
|
||
"проверь, что констрейнт совпадает с полем DaData `region` (без типа)",
|
||
query[:60],
|
||
region_name,
|
||
)
|
||
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, region_code: int = 66
|
||
) -> list[dict]:
|
||
"""Один Nominatim search с рамкой региона `region_code`. Возвращает up to N items.
|
||
|
||
`region_code=66` (дефолт) — byte-identical прежнему поведению: та же
|
||
viewbox-строка `OBLAST66_VIEWBOX` (см. `_viewbox_for_region`).
|
||
"""
|
||
await _nominatim_throttle()
|
||
response = await client.get(
|
||
"https://nominatim.openstreetmap.org/search",
|
||
params={
|
||
"q": query,
|
||
"format": "json",
|
||
"limit": str(limit),
|
||
"countrycodes": "ru",
|
||
"viewbox": _viewbox_for_region(region_code),
|
||
"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,
|
||
region_code: int = 66,
|
||
) -> 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, region_code=region_code
|
||
)
|
||
if city_specified:
|
||
return await _nominatim_query_multi(client, query, limit, region_code=region_code)
|
||
# Город неизвестен — dual-query с суффиксом главного города региона
|
||
# (66 → "Екатеринбург", byte-identical; прочие — см. `_region_default_city`).
|
||
default_city = _region_default_city(region_code)
|
||
city_data = await _nominatim_query_multi(
|
||
client, f"{query}, {default_city}", limit, region_code=region_code
|
||
)
|
||
bare_data = await _nominatim_query_multi(client, query, limit, region_code=region_code)
|
||
return _dedupe_nominatim_items(city_data, bare_data)[:limit]
|
||
|
||
|
||
# reraise=True — см. комментарий у `_nominatim_lookup` (GlitchTip RetryError-шум).
|
||
@retry(stop=stop_after_attempt(2), wait=wait_exponential(multiplier=1, min=1, max=4), reraise=True)
|
||
async def _nominatim_suggest(
|
||
query: str, limit: int = 8, city_hint: str | None = None, region_code: int = 66
|
||
) -> 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, region_code)
|
||
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, region_code=region_code
|
||
)
|
||
|
||
# Tier 2: typo-варианты если оригинал пустой
|
||
if not data:
|
||
for variant in _typo_variants(query, limit=3):
|
||
variant_city, variant_specified = _resolve_city_for_geocode(
|
||
variant, city_hint, region_code
|
||
)
|
||
data = await _nominatim_query_city_aware(
|
||
client,
|
||
variant,
|
||
variant_city,
|
||
variant_specified,
|
||
limit,
|
||
region_code=region_code,
|
||
)
|
||
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 → `_local_houses_match` (#2626, houses-фолбэк)
|
||
→ None. Тира DaData тут НЕТ.
|
||
* `suggest()` : cadastral → DaData → Nominatim (единственный вызов
|
||
`_dadata_suggest`).
|
||
То есть на прямом вызове `geocode()` (API/PDF/восстановление по `?id=`)
|
||
адрес с литерой, неизвестный ни геопорталу, ни Nominatim, ни houses-фолбэку,
|
||
даёт 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",
|
||
)
|
||
|
||
|
||
# ── Local `houses` fallback (#2626) — последний тир geocode() ───────────────
|
||
# Мотивация: 28/1084 прод-оценок с lat IS NULL — гарантированный ноль аналогов,
|
||
# клиент не получает оценку вовсе. Живые примеры (адрес пользователя → ГАР/houses):
|
||
# «ул Крестинского, д 49» — «49» голого нет в houses, есть только «49к1»
|
||
# (корпус потерян при вводе, houses id 9980 «улица Крестинского, 49к1»);
|
||
# «ул Онуфриева, д 24» — houses называет улицу «Начдива Онуфриева» (ГАР),
|
||
# пользователь пишет только последнее слово имени.
|
||
# Дом уже ЕСТЬ в `houses` (скрейпленные листинги avito/cian/derived/yandex) с
|
||
# координатами — Nominatim и ЕКБ-реестры (geoportal/cad_buildings) эти формы не
|
||
# резолвят, а houses чаще содержит именно то написание, которым реально пользуются
|
||
# люди (агрегировано из объявлений, а не из официального ГАР).
|
||
#
|
||
# Номер дома в `houses.address` — СВОБОДНЫЙ текст источников (avito/cian/derived/
|
||
# yandex_valuation): «улица X, 49к1» / «X ул.,88/2» / «X, 44» — БЕЗ единого формата
|
||
# и без «д./дом»-маркера, в отличие от `gendesign_cad_buildings.readable_address`.
|
||
# Поэтому здесь — собственная, более широкая нормализация номера (со слэшем
|
||
# «88/2» и корпусом «49к1»), а НЕ переиспользование `_HOUSE_NUM`/`_norm_house`
|
||
# (те заточены под geoportal/cad_buildings реестры, где «/N» и «корпус N» реже).
|
||
_LOCAL_HOUSE_TOKEN_RE = re.compile(
|
||
r"(\d+(?:\s*/\s*\d+)?(?:\s*-?\s*(?:к|корп\.?|корпус)\.?\s*-?\s*\d+)?(?:\s*-?\s*[а-яё])?)",
|
||
re.IGNORECASE,
|
||
)
|
||
|
||
|
||
def _norm_local_house(raw: str) -> str:
|
||
"""Канон номера дома для houses-фолбэка.
|
||
|
||
«49 к 1» / «49-к1» / «49 корпус 1» → «49к1»; «88 / 2» → «88/2»; «35А» → «35а».
|
||
"""
|
||
s = raw.strip().lower()
|
||
s = re.sub(r"\s+", "", s)
|
||
s = re.sub(r"корпус|корп\.?", "к", s)
|
||
s = re.sub(r"-(к\d+)", r"\1", s)
|
||
s = re.sub(r"-([а-яё])$", r"\1", s)
|
||
return s
|
||
|
||
|
||
# Хвостовой мусор ПОСЛЕ номера дома — квартира/офис/помещение/подъезд/этаж.
|
||
# НЕ включает «корп/корпус/к» (в отличие от `_RE_APT_TAIL` выше) — корпус тут
|
||
# ЧАСТЬ номера дома, который должен остаться видимым для `_LOCAL_HOUSE_TOKEN_RE`
|
||
# («49к1», «26 к 1» — корпус нельзя терять). Без этой зачистки
|
||
# `_extract_local_house_token` (берёт ПОСЛЕДНЕЕ число в строке) находит номер
|
||
# квартиры/этажа вместо дома — прод-баг #2626 review R2 #1: «...Педагогическая,
|
||
# д 15, кв 11» отдавал дом «11» (координаты ЧУЖОГО здания) вместо «15».
|
||
_RE_LOCAL_APT_TAIL = re.compile(
|
||
r"[,\s]\s*(?:кв|квартира|оф|офис|пом|помещение|лит|подъезд|этаж)\.?\s*\d.*$",
|
||
re.IGNORECASE,
|
||
)
|
||
|
||
|
||
def _extract_local_house_token(address: str) -> str | None:
|
||
"""Номер дома из ПОЛЬЗОВАТЕЛЬСКОГО адреса — с учётом «/N» и «корпус N» хвостов,
|
||
которые `_parse_street_house`/`_HOUSE_NUM` обрезают (см. коммент у
|
||
`_LOCAL_HOUSE_TOKEN_RE`). Берём ПОСЛЕДНЕЕ совпадение — номер дома в русском
|
||
адресе почти всегда в хвосте строки. None, если цифр нет вовсе.
|
||
|
||
Квартирный/этажный/подъездный хвост зачищается ДО поиска номера
|
||
(`_RE_LOCAL_APT_TAIL`) — иначе «последнее число в строке» это номер
|
||
квартиры/этажа, а не дома (см. докстринг у `_RE_LOCAL_APT_TAIL`).
|
||
"""
|
||
s = _RE_POSTAL.sub(" ", " ".join(address.lower().strip().split())).strip(" ,.")
|
||
if not s:
|
||
return None
|
||
s = _RE_LOCAL_APT_TAIL.sub(" ", s).strip(" ,.")
|
||
if not s:
|
||
return None
|
||
matches = list(_LOCAL_HOUSE_TOKEN_RE.finditer(s))
|
||
if not matches:
|
||
return None
|
||
return _norm_local_house(matches[-1].group(1))
|
||
|
||
|
||
# Маркеры района/города/страны — обрезаются из `houses.address` перед сравнением
|
||
# улицы (`_clean_local_house_street`). Хвостовое сравнение (см. ниже) и без этого
|
||
# устойчиво к ЛИШНЕМУ префиксу («р-н Ленинский, мкр. Юго-Западный, улица X» всё
|
||
# равно оканчивается на «... улица x» и матчит суффиксом), но тип улицы ПОСЛЕ
|
||
# имени («Хрустальногорская ул.») ломает суффикс без явной зачистки типа.
|
||
# Хвостовой якорь — lookahead на пробел/конец строки, а НЕ `\b`: «ул.» в самом
|
||
# конце сегмента (частая форма в houses.address) заканчивается точкой, а `\b`
|
||
# сразу после точки на границе строки не срабатывает (оба «символа» не-\w) —
|
||
# тип-слово матчилось бы БЕЗ точки, точка оставалась бы висеть («хрустальногорская .»)
|
||
# и ломала «хвостовое» сравнение улицы (реальный прод-кейс: id 13080 houses).
|
||
_LOCAL_HOUSE_STREET_TYPE_RE = re.compile(rf"\b(?:{_STREET_TYPE})\.?(?=\s|$)", re.IGNORECASE)
|
||
|
||
|
||
def _clean_local_house_street(segment: str) -> str:
|
||
"""«Хрустальногорская ул.» / «улица Начдива Онуфриева» → «хрустальногорская» /
|
||
«начдива онуфриева»: lower, без типа улицы, схлопнутые пробелы.
|
||
|
||
Общая нормализация и для запроса пользователя (уже typeless из
|
||
`_parse_street_house`, но повторный проход — no-op), и для `houses.address`.
|
||
"""
|
||
s = _LOCAL_HOUSE_STREET_TYPE_RE.sub(" ", segment.lower())
|
||
return " ".join(s.split())
|
||
|
||
|
||
def _row_local_house(address: str) -> tuple[str, str] | None:
|
||
"""Разбирает ОДНУ строку `houses.address` на (street_clean, house_norm).
|
||
|
||
Номер дома — ПОСЛЕДНИЙ через-запятую сегмент (во всех живых формах: «X, 49к1»,
|
||
«X ул.,88/2», «X, 44»), СОВПАДЕНИЕ С НАЧАЛА этого сегмента (не всей строки) —
|
||
покрывает и «49к1» целиком, и «35к1 · р-н Академический» (хвостовой мусор
|
||
после номера отбрасывается). Известный неполный случай (не встретился в
|
||
выборке): номер дома БЕЗ запятой перед ним — вернёт None, строка просто не
|
||
станет кандидатом (не ложный матч).
|
||
"""
|
||
segments = [s.strip() for s in address.split(",") if s.strip()]
|
||
if len(segments) < 2:
|
||
return None
|
||
m = _LOCAL_HOUSE_TOKEN_RE.match(segments[-1])
|
||
if not m:
|
||
return None
|
||
house_norm = _norm_local_house(m.group(1))
|
||
street_norm = _clean_local_house_street(" ".join(segments[:-1]))
|
||
if not street_norm or not house_norm:
|
||
return None
|
||
return street_norm, house_norm
|
||
|
||
|
||
def _street_tail_matches(row_street_norm: str, query_street_norm: str) -> bool:
|
||
"""True если `query_street_norm` — «хвост» (последнее слово/слова) имени улицы
|
||
в `houses` — «онуфриева» находит «начдива онуфриева» (ГАР-каноничное имя),
|
||
регистронезависимо. Точное равенство тоже проходит (частый случай — короткие
|
||
однословные улицы, «Малышева» == «Малышева»)."""
|
||
return row_street_norm == query_street_norm or row_street_norm.endswith(" " + query_street_norm)
|
||
|
||
|
||
# «24к1» → «24» (базовый номер варианта с корпусом/слэшем); «44» (голый номер,
|
||
# без суффикса) → None. Используется ТОЛЬКО для sibling-guard (см. ниже) —
|
||
# отличить «этот дом однозначно к1» от «этого дома несколько корпусов, а у
|
||
# нас в вводе просто нет данных, какой именно».
|
||
_LOCAL_HOUSE_VARIANT_BASE_RE = re.compile(r"^(\d+)(?:к\d+|/\d+)$")
|
||
|
||
|
||
def _local_houses_match(db: Session, street: str, house: str) -> GeocodeSuggestion | None:
|
||
"""Последний локальный тир `geocode()` (#2626) — fallback на `houses`
|
||
(скрейпленные листинги avito/cian/derived/yandex, own DB table, БЕЗ FDW).
|
||
|
||
Вызывается ТОЛЬКО когда geoportal/cadastral/Nominatim уже не дали результата.
|
||
Допущения, все defensive (при неоднозначности — None, не гадаем):
|
||
|
||
1. Улица матчится «по хвосту» (`_street_tail_matches`) — ловит расхождение
|
||
разговорного/сокращённого имени («Онуфриева») и канонического ГАР-имени в
|
||
houses («Начдива Онуфриева»).
|
||
2. Координаты строки-кандидата обязаны лежать в широком ЕКБ-bbox
|
||
(`is_within_ekb_bbox_wide`) — `houses` НЕ ЕКБ-only реестр (в отличие от
|
||
geoportal/cad_buildings): 21% строк с координатами лежат вне области ЕКБ,
|
||
местами вплоть до другого региона (#2626 review R2 #2 — прод-пример
|
||
«улица Маяковского, 7» в houses это Серов, а не запрошенный
|
||
Екатеринбург). `use_local_ekb` в `geocode()` гейтит только ЗАПРОС
|
||
пользователя, не страхует от грязной строки-источника.
|
||
3. Номер дома — сперва точное совпадение; нет — пробуем `<номер>к1` (частый
|
||
случай: пользователь ввёл «49», у дома есть только корпус «49к1»), но
|
||
ТОЛЬКО если среди кандидатов улицы НЕТ других корпусов/дробей этого же
|
||
номера («24к2», «24/2» и т.п.) — иначе «к1» такая же угадайка, как и
|
||
любой другой корпус, и реальные дома могут быть в 250-400м друг от друга
|
||
(#2626 review R2 #3, прод-пример «Начдива Онуфриева, 24»: 24к1/24к2/24к3
|
||
— три разных здания).
|
||
4. ЛЮБОЙ шаг, где кандидатов больше одного (после дедупа по округлённым
|
||
координатам — разные source-строки ОДНОГО дома не в счёт), возвращает
|
||
None — угадывать нельзя.
|
||
|
||
SQL — дешёвый ILIKE-префильтр по последнему слову улицы (нет индекса на
|
||
`houses.address`, но тир последний и редкий — не на каждый запрос) с
|
||
детерминированным ORDER BY (дедуп по координатам иначе непредсказуемо
|
||
выбирал бы, какая из двух ~идентичных source-строк станет ответом —
|
||
#2626 review R2 #5); вся точная логика (суффикс улицы, bbox, равенство
|
||
номера) — в Python, что и делает её юнит-тестируемой без реальной БД
|
||
(см. `test_geocoder_local_houses_fallback.py`).
|
||
|
||
Результат этого тира НЕ кэшируется в `geocode_cache` вызывающей стороной
|
||
(см. `geocode()`) — `houses`-координаты из скрейпленных объявлений менее
|
||
надёжны, чем geoportal/cadastral/Nominatim, а сам lookup дешёвый и локальный
|
||
(#2626 review R2 #4).
|
||
"""
|
||
query_street_norm = _clean_local_house_street(street)
|
||
if not query_street_norm:
|
||
return None
|
||
query_house_norm = _norm_local_house(house)
|
||
if not query_house_norm:
|
||
return None
|
||
last_word = query_street_norm.split()[-1]
|
||
|
||
try:
|
||
rows = db.execute(
|
||
text("""
|
||
SELECT address, lat, lon
|
||
FROM houses
|
||
WHERE address ILIKE CAST('%' || :w || '%' AS text)
|
||
AND lat IS NOT NULL AND lon IS NOT NULL
|
||
ORDER BY address, id
|
||
"""),
|
||
{"w": last_word},
|
||
).fetchall()
|
||
except Exception:
|
||
logger.warning(
|
||
"local houses fallback query failed for street=%r house=%r",
|
||
street,
|
||
house,
|
||
exc_info=True,
|
||
)
|
||
return None
|
||
|
||
# Street-tail + bbox фильтр — один проход, дальше переиспользуется и для
|
||
# точного совпадения, и для corpus-1 догадки, и для sibling-guard.
|
||
street_rows: list[tuple[str, float, float, str]] = [] # (house_norm, lat, lon, addr)
|
||
for r in rows:
|
||
parsed = _row_local_house(str(r.address or ""))
|
||
if parsed is None:
|
||
continue
|
||
row_street_norm, row_house_norm = parsed
|
||
if not _street_tail_matches(row_street_norm, query_street_norm):
|
||
continue
|
||
lat, lon = float(r.lat), float(r.lon)
|
||
if not is_within_ekb_bbox_wide(lat, lon):
|
||
continue
|
||
street_rows.append((row_house_norm, lat, lon, str(r.address)))
|
||
|
||
def _candidates(house_norm: str) -> list[tuple[str, float, float]]:
|
||
out: list[tuple[str, float, float]] = []
|
||
seen_coords: set[tuple[float, float]] = set()
|
||
for row_house_norm, lat, lon, addr in street_rows:
|
||
if row_house_norm != house_norm:
|
||
continue
|
||
coord_key = (round(lat, 4), round(lon, 4)) # ~11m — дедуп источников
|
||
if coord_key in seen_coords:
|
||
continue
|
||
seen_coords.add(coord_key)
|
||
out.append((addr, lat, lon))
|
||
return out
|
||
|
||
exact = _candidates(query_house_norm)
|
||
if len(exact) == 1:
|
||
addr, lat, lon = exact[0]
|
||
return GeocodeSuggestion(label=addr, full_address=addr, lat=lat, lon=lon, kind="house")
|
||
if len(exact) > 1:
|
||
logger.info(
|
||
"local houses fallback: %d неоднозначных кандидата для %r %r — skip",
|
||
len(exact),
|
||
street,
|
||
house,
|
||
)
|
||
return None
|
||
|
||
# Точного номера нет — пробуем «<номер>к1» (корпус потерян при вводе), ТОЛЬКО
|
||
# если запрошенный номер — голое число (не пытаемся достраивать «49/2» → «49/2к1»).
|
||
if query_house_norm.isdigit():
|
||
corpus1 = f"{query_house_norm}к1"
|
||
siblings = {
|
||
row_house_norm
|
||
for row_house_norm, _lat, _lon, _addr in street_rows
|
||
if row_house_norm != corpus1
|
||
and (m := _LOCAL_HOUSE_VARIANT_BASE_RE.match(row_house_norm)) is not None
|
||
and m.group(1) == query_house_norm
|
||
}
|
||
if siblings:
|
||
logger.info(
|
||
"local houses fallback: корпус-1 %r неоднозначен — есть другие "
|
||
"корпуса/дроби %s — skip",
|
||
corpus1,
|
||
sorted(siblings),
|
||
)
|
||
return None
|
||
guessed = _candidates(corpus1)
|
||
if len(guessed) == 1:
|
||
addr, lat, lon = guessed[0]
|
||
logger.info("local houses fallback: %r → корпус-1 %r (%s)", house, corpus1, addr)
|
||
return GeocodeSuggestion(label=addr, full_address=addr, lat=lat, lon=lon, kind="house")
|
||
if len(guessed) > 1:
|
||
logger.info(
|
||
"local houses fallback: корпус-1 %r неоднозначен (%d кандидата) — skip",
|
||
corpus1,
|
||
len(guessed),
|
||
)
|
||
return None
|
||
|
||
|
||
def _cadastral_reverse_sync(db: Session, lat: float, lon: float, radius_m: int = 200) -> str | None:
|
||
"""Reverse lookup via gendesign_cad_buildings FDW.
|
||
|
||
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,
|
||
region_code: int = 66,
|
||
) -> 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 ей не нужен.
|
||
region_code: регион покрытия (дефолт 66, #3051) — какой регион уходит в
|
||
hard-констрейнты провайдеров: DaData `region` (`_dadata_region_name`) и
|
||
Nominatim `viewbox`+bounded (`_viewbox_for_region`). БЕЗ него московский
|
||
адрес молча схлопывался в пустой список: оба констрейнта — ФИЛЬТРЫ, а не
|
||
boost, и «не тот регион» неотличимо от «адрес не найден». Локальные
|
||
ЕКБ-тиры (кадастр) для region_code != 66 пропускаются целиком — данных
|
||
по другим регионам в FDW физически нет. Дефолт — byte-identical
|
||
прежнему поведению по Свердловской области.
|
||
Без кэша (дешёво, провайдеры толерируют автокомплит-запросы).
|
||
"""
|
||
if not query or len(query.strip()) < 2:
|
||
return []
|
||
try:
|
||
_ALL_REGIONS[region_code]
|
||
except KeyError as exc:
|
||
raise ValueError(f"suggest: unknown region_code={region_code!r}") from exc
|
||
|
||
# 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.
|
||
# #3051: `region_code != 66` закрывает кадастровый тир ДО `_ekb_local_tiers_allowed`
|
||
# — gendesign_cad_buildings содержит только ЕКБ, звать его для Москвы значит
|
||
# платить FDW-round-trip ради гарантированного нуля (тот же гейт в
|
||
# `_geocode_resolve`; сигнатуру `_ekb_local_tiers_allowed` умышленно не трогаем).
|
||
if db is not None and region_code == 66 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 run_db_thread(_cadastral_house_match, db, street, house)
|
||
if hit is not None:
|
||
return [hit]
|
||
# 1b. Fallback: legacy raw-ILIKE forward search (для нераспарсенных форм)
|
||
cad_results = await run_db_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, region_code)
|
||
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, region_code=region_code)
|
||
except Exception:
|
||
logger.exception("nominatim suggest failed")
|
||
return []
|
||
|
||
|
||
# ── Public API ───────────────────────────────────────────────────────────────
|
||
async def geocode(
|
||
address: str, db: Session, city_hint: str | None = None, region_code: int = 66
|
||
) -> GeocodeResult | None:
|
||
"""Геокодинг с кэшем + постфактум-проверка подмены города (#2590).
|
||
|
||
Тонкая обёртка над `_geocode_resolve` (вся тировая цепочка там). Инвариант
|
||
применяется ОДНОЙ точкой на выходе — поэтому покрывает разом все источники,
|
||
включая попадание в кэш: отравленная запись, записанная до этого фикса,
|
||
больше не отдаётся как точная, хотя строка в `geocode_cache` не тронута
|
||
(обратимо: откат кода возвращает прежнее поведение, чистить БД не требуется).
|
||
|
||
Сработал инвариант → `confidence="locality"` + `city_ambiguous=True`.
|
||
`locality` — не косметика: `estimator._geocode_is_coarse` уже трактует его
|
||
как «геокодер дошёл только до центра НП» и (а) включает #693 coarse-downgrade
|
||
оценки, (б) через `tasks.geocode_missing` проставляет листингу
|
||
`geo_precision='city'`, а этот признак исключает листинг из пула аналогов
|
||
(`estimator`/`location_index`: `geo_precision IS DISTINCT FROM 'city'`).
|
||
То есть объявление, уехавшее координатами в чужой город, перестаёт тянуть
|
||
за собой чужие оценки. Координаты НЕ выбрасываются — деградация честная и
|
||
видимая, а не отказ.
|
||
|
||
`region_code` (дефолт 66, #3051) — какой `REGIONS`-регион искать (bbox,
|
||
city-словарь, ЕКБ-only локальные тиры). Неизвестный код → `ValueError`
|
||
сразу, а не глубоко внутри `_nominatim_query`. Все существующие вызовы без
|
||
аргумента получают region_code=66 — byte-identical прежнему поведению.
|
||
`_city_substituted` region_code не принимает: инвариант завязан на
|
||
ЕКБ-bbox координат результата (`is_within_ekb_bbox`), который для другого
|
||
региона (Москва и т.п.) структурно не совпадает — условие 3 инварианта
|
||
никогда не сработает, ложного понижения confidence до "locality" не будет.
|
||
"""
|
||
try:
|
||
_ALL_REGIONS[region_code]
|
||
except KeyError as exc:
|
||
raise ValueError(f"geocode: unknown region_code={region_code!r}") from exc
|
||
result = await _geocode_resolve(address, db, city_hint, region_code)
|
||
if result is None or not _city_substituted(address, result):
|
||
return result
|
||
logger.warning(
|
||
"geocode city substitution (#2590): %r → %r (%.5f, %.5f) provider=%s — "
|
||
"названный НП не дожил до ответа, результат внутри ЕКБ; confidence→locality",
|
||
address[:80],
|
||
(result.full_address or "")[:80],
|
||
result.lat,
|
||
result.lon,
|
||
result.provider,
|
||
)
|
||
return replace(result, confidence="locality", city_ambiguous=True)
|
||
|
||
|
||
async def _geocode_resolve(
|
||
address: str, db: Session, city_hint: str | None = None, region_code: int = 66
|
||
) -> GeocodeResult | None:
|
||
"""Геокодинг с кэшем. Cadastral FDW → Nominatim → None.
|
||
|
||
Args:
|
||
address: пользовательский ввод (может быть грязным — нормализуем).
|
||
db: сессия Postgres для cache lookup/write и cadastral FDW lookup.
|
||
city_hint: город, если известен вызывающему коду (#2576) — например
|
||
выбран пользователем на предыдущем шаге UI. Опциональный, не
|
||
ломает существующий контракт. Прокидывается в Nominatim-тир
|
||
вместо молчаливой подстановки "Екатеринбург" и
|
||
участвует в cache-ключе (см. `_cache_key`), чтобы ответы для
|
||
разных городов по одному и тому же тексту адреса не перезатирали
|
||
друг друга.
|
||
region_code: регион покрытия (дефолт 66, #3051). ЕКБ-only локальные
|
||
тиры (geoportal/cad_buildings/houses) применяются ТОЛЬКО при 66 —
|
||
это ЕКБ-специфичные реестры, у других регионов данных в них нет.
|
||
Прокидывается в Nominatim-тир (bbox/viewbox/city-словарь).
|
||
|
||
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, region_code)
|
||
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 run_db_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).
|
||
#
|
||
# #3051: `region_code != 66` закрывает эти тиры целиком, ДО вызова
|
||
# `_ekb_local_tiers_allowed` — geoportal/cad_buildings/houses физически не
|
||
# содержат данных других регионов (не "город не распознан словарём 66", а
|
||
# "реестра для этого региона нет вовсе"), а сама `_ekb_local_tiers_allowed`
|
||
# (её ЕКБ-словари: `_names_non_ekb_city`/`_names_unrecognized_locality`)
|
||
# region_code не принимает — умышленно не трогаем её сигнатуру.
|
||
use_local_ekb = region_code == 66 and _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 run_db_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 run_db_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 run_db_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 run_db_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 run_db_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 run_db_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, region_code)
|
||
if result is not None:
|
||
result = replace(result, city_ambiguous=city_ambiguous)
|
||
await run_db_thread(_cache_put, db, addr_norm, result)
|
||
logger.info("geocode nominatim: %s → (%.5f, %.5f)", addr_norm, result.lat, result.lon)
|
||
return result
|
||
except Exception:
|
||
logger.exception("nominatim geocoder failed")
|
||
|
||
# 4. Local `houses` fallback (#2626) — САМЫЙ ПОСЛЕДНИЙ тир, до возврата None.
|
||
# 28/1084 прод-оценок имели lat IS NULL (гарантированный ноль аналогов) — дом
|
||
# был в `houses` (скрейпленные листинги), но не в geoportal/cad_buildings и не
|
||
# резолвился Nominatim'ом (разговорное/усечённое имя улицы или отсутствующий
|
||
# в вводе корпус). См. `_local_houses_match`. EKB-only гейт — тот же, что у
|
||
# geoportal/cadastral (houses — преимущественно ЕКБ-трафик, тот же риск
|
||
# коллизии улица+дом с другим городом региона, что и мотивировал #2582);
|
||
# координаты строки-кандидата ДОПОЛНИТЕЛЬНО проверяются bbox-ом внутри
|
||
# `_local_houses_match` (гейт здесь фильтрует только запрос пользователя,
|
||
# не грязь в самой таблице — #2626 review R2 #2).
|
||
if use_local_ekb and parsed is not None:
|
||
local_street, _parsed_house = parsed
|
||
local_house = _extract_local_house_token(address) or _parsed_house
|
||
hit = await run_db_thread(_local_houses_match, db, local_street, local_house)
|
||
if hit is not None:
|
||
result = GeocodeResult(
|
||
lat=hit.lat,
|
||
lon=hit.lon,
|
||
full_address=hit.full_address,
|
||
provider="cache", # локальный DB-lookup, без внешнего HTTP — как geoportal
|
||
confidence="exact",
|
||
city_ambiguous=city_ambiguous,
|
||
address_refined=True,
|
||
)
|
||
# НЕ кэшируем: houses-координаты (скрейпленные листинги) менее
|
||
# надёжны, чем geoportal/cadastral/Nominatim, а сам lookup дешёвый
|
||
# и локальный — кэш только продлевал бы жизнь возможной ошибке
|
||
# источника (#2626 review R2 #4). Побочный эффект: `address_refined`
|
||
# переживает КАЖДЫЙ повторный запрос этого сырого адреса, а не
|
||
# только первый (было известным пределом до этого фикса).
|
||
logger.info(
|
||
"geocode local houses fallback: %s → (%.5f, %.5f) [%s]",
|
||
addr_norm,
|
||
result.lat,
|
||
result.lon,
|
||
hit.full_address,
|
||
)
|
||
return result
|
||
|
||
return None
|
||
|
||
|
||
# ── Reverse: координаты → адрес (для map-picker'а) ──────────────────────────
|
||
# Precision levels which we treat as "снап к зданию имеет смысл":
|
||
# - exact — точный матч на здание. #2593: единственный живой источник
|
||
# (`_yandex_reverse`) удалён — значение сейчас недостижимо ни одним из
|
||
# оставшихся тиров (cadastral/Nominatim), но оставлено в множестве:
|
||
# дёшево держать (frozen set membership-check, не enum), сохраняет parity
|
||
# с фронтовым `SNAP_PRECISIONS` (`MapPicker.tsx`, тот же литерал), и не
|
||
# ломает `test_snap_precision_useful_exact_and_number`.
|
||
# - number — найден дом с номером (то что нам надо для квартирного оценщика)
|
||
# - cadastral — Cadastral FDW row (та же точность, что и "number")
|
||
# Остальные (street/range/near/locality/other) → не снапаем, marker остаётся на клике.
|
||
_SNAP_PRECISIONS = {"exact", "number", "cadastral"}
|
||
|
||
|
||
@dataclass(frozen=True, slots=True)
|
||
class ReverseGeocodeResult:
|
||
"""Reverse-геокодинг с snapped координатами matched здания.
|
||
|
||
- `address` — текстовый адрес (улица + дом + город).
|
||
- `snapped_lat` — координата центра здания если provider дал её,
|
||
иначе echo `lat` входной точки (для precision=street/locality).
|
||
- `snapped_lon` — то же.
|
||
- `precision` — `exact`/`number`/`street`/`range`/`near`/`locality`/
|
||
`other`/`cadastral`. Используется фронтом чтобы
|
||
решить — двигать marker (exact/number) или нет.
|
||
- `provider` — кто дал результат (`nominatim`/`cadastral`).
|
||
|
||
Фронт MapPicker'а после клика смотрит на precision: если `exact`/`number`
|
||
и snapped >5m от click point — пересаживает marker на snapped point
|
||
(чтобы пользователь видел центр дома, а не свой клик во дворе).
|
||
Для остальных precision marker остаётся где кликнули — не врём что нашли
|
||
точное здание.
|
||
"""
|
||
|
||
address: str
|
||
snapped_lat: float
|
||
snapped_lon: float
|
||
precision: str
|
||
provider: Literal["nominatim", "cadastral"]
|
||
|
||
|
||
def _format_reverse_address(addr: dict) -> str | None:
|
||
"""Собирает чистый уличный адрес из Nominatim address-объекта.
|
||
|
||
Nominatim `display_name` ведёт с названия ближайшего POI/организации
|
||
(«NataliOlympic, 2к1, Трамвайный переулок, …»). Для map-picker'а это
|
||
ломает кейс: такой адрес нельзя forward-геокодировать обратно и оценка
|
||
не находит аналогов. Берём именно улицу + дом + город.
|
||
|
||
Возвращает None если в объекте нет улицы — тогда вызывающий код
|
||
откатывается на display_name.
|
||
"""
|
||
road = addr.get("road") or addr.get("pedestrian") or addr.get("footway")
|
||
if not road:
|
||
return None
|
||
house = addr.get("house_number")
|
||
city = addr.get("city") or addr.get("town") or addr.get("village") or addr.get("municipality")
|
||
parts: list[str] = [str(road)]
|
||
if house:
|
||
parts.append(str(house))
|
||
if city:
|
||
parts.append(str(city))
|
||
return ", ".join(parts)
|
||
|
||
|
||
# reraise=True — см. комментарий у `_nominatim_lookup` (GlitchTip RetryError-шум).
|
||
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=8), reraise=True)
|
||
async def _nominatim_reverse(lat: float, lon: float) -> ReverseGeocodeResult | None:
|
||
"""Nominatim /reverse → ReverseGeocodeResult с snapped coords из item.lat/lon.
|
||
|
||
Nominatim возвращает координаты центра matched feature (building/way/node).
|
||
precision выводим из наличия `house_number` в addressdetails.
|
||
"""
|
||
headers = {
|
||
"User-Agent": f"TradeInMVP/0.1 (contact: {settings.contact_email})",
|
||
"Accept": "application/json",
|
||
"Accept-Language": "ru,en;q=0.8",
|
||
}
|
||
async with httpx.AsyncClient(timeout=10.0, headers=headers) as client:
|
||
await _nominatim_throttle()
|
||
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 run_db_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
|