gendesign/tradein-mvp/backend/app/services/geocoder.py
bot-backend c251c02f1e
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
Отмена по бюджету больше не оставляет сироту в сессии запроса (#3449)
`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>
2026-09-12 13:20:44 +05:00

2352 lines
140 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

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

"""Geocoder service — address → lat/lon.
Стратегия:
- Cache lookup в `geocode_cache` (Postgres) — TTL 90 дней
- Cache miss → локальные ЕКБ-тиры (геопортал/кадастр) → fallback Nominatim
- Результат сохраняется в кэш для последующих вызовов
Используется в:
- /api/v1/trade-in/estimate (вход — адрес от пользователя)
- /api/v1/geocode/lookup (debug endpoint)
- scraper jobs (когда нужно по адресу определить координаты)
"""
from __future__ import annotations
import asyncio
import logging
import re
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