chore(tradein/geocoder): удалить Яндекс-геокодер (#2593) #2596

Merged
lekss361 merged 1 commit from chore/tradein-remove-yandex-geocoder into main 2026-07-31 19:04:04 +00:00
12 changed files with 168 additions and 1013 deletions

View file

@ -124,11 +124,11 @@ class ReverseResponse(BaseModel):
precision: str = Field(
...,
description=(
"Yandex-style: exact/number/street/range/near/locality/other/cadastral. "
"exact/number/street/range/near/locality/other/cadastral. "
"Фронт двигает marker только если exact/number/cadastral."
),
)
provider: str = Field(..., description="cadastral | yandex | nominatim")
provider: str = Field(..., description="cadastral | nominatim")
@router.get("/reverse", response_model=ReverseResponse)

View file

@ -71,11 +71,6 @@ class Settings(BaseSettings):
default=300, validation_alias="LOGIN_RATE_LIMIT_WINDOW_S"
)
# Geocoder. Env var name `YANDEX_GEOCODER_API_KEY` — consistent с scripts/
# backfill_house_coords.py + audit_address_mismatch.py + main backend
# OpenRouteService_API_KEY pattern. Renamed from YANDEX_GEOCODER_KEY (PR F).
yandex_geocoder_api_key: str | None = None # 25K req/day free после регистрации
yandex_suggest_key: str | None = None # для frontend autocomplete (proxy через backend)
# для User-Agent в Nominatim (Nominatim Usage Policy)
contact_email: str = "erginrajpopxbe@outlook.com"

View file

@ -8,6 +8,7 @@ This helper:
- applies idempotent CREATE or ALTER mapping on every backend startup so
password rotation through .env.runtime is picked up after restart.
"""
from __future__ import annotations
import logging
@ -37,7 +38,7 @@ def ensure_fdw_user_mapping(db: Session) -> None:
logger.warning(
"GENDESIGN_FDW_PASSWORD not set — skipping FDW user mapping "
"(gendesign_cad_buildings queries will fail; cadastral lookups will "
"fall back to Yandex/Nominatim)"
"fall back to Nominatim)"
)
return
@ -62,16 +63,20 @@ def ensure_fdw_user_mapping(db: Session) -> None:
).first()
if exists is None:
db.execute(text(
f"CREATE USER MAPPING FOR CURRENT_USER SERVER gendesign_remote "
f"OPTIONS (user 'tradein_fdw_reader', password '{password}')"
))
db.execute(
text(
f"CREATE USER MAPPING FOR CURRENT_USER SERVER gendesign_remote "
f"OPTIONS (user 'tradein_fdw_reader', password '{password}')"
)
)
logger.info("created FDW user mapping for gendesign_remote")
else:
db.execute(text(
f"ALTER USER MAPPING FOR CURRENT_USER SERVER gendesign_remote "
f"OPTIONS (SET password '{password}')"
))
db.execute(
text(
f"ALTER USER MAPPING FOR CURRENT_USER SERVER gendesign_remote "
f"OPTIONS (SET password '{password}')"
)
)
logger.info("refreshed FDW user mapping password for gendesign_remote")
try:

View file

@ -3201,12 +3201,13 @@ async def estimate_quality(
Returns:
AggregatedEstimate с estimate_id, медианой, диапазоном, аналогами.
"""
# 1. Geocode (#654: time-budgeted — Yandex/Nominatim retry chain can stack
# 1. Geocode (#654: time-budgeted — Nominatim retry chain can stack
# multiple network round-trips + 1s Nominatim rate-limit sleeps).
geo: GeocodeResult | None = None
# Variant A: trust client-provided coords (resolved by autocomplete/map) when present
# and inside the oblast bbox — skips the geocode() chain that fails on DaData-format
# addresses with the Yandex key dead. Out-of-bbox / partial → ignore, geocode normally.
# addresses (#2593: Yandex Geocoder, the previous fallback for those, removed).
# Out-of-bbox / partial → ignore, geocode normally.
# (oblast C2): was tight EKB-only bbox (60.40-60.85 / 56.65-56.95) — widened to
# geocoder.is_within_oblast66_bbox (region 66) so client-coords from oblast towns also
# get this perf fast-path instead of always paying the geocode() round-trip. Perf-only,

View file

@ -2,7 +2,7 @@
Стратегия:
- Cache lookup в `geocode_cache` (Postgres) TTL 90 дней
- Cache miss Yandex Geocoder (если есть key) fallback Nominatim
- Cache miss локальные ЕКБ-тиры (геопортал/кадастр) fallback Nominatim
- Результат сохраняется в кэш для последующих вызовов
Используется в:
@ -36,7 +36,7 @@ class GeocodeResult:
lat: float
lon: float
full_address: str
provider: Literal["nominatim", "yandex", "cache"]
provider: Literal["nominatim", "cache"]
confidence: Literal["exact", "approximate", "locality"] = "approximate"
# #2576: True если город НЕ был указан пользователем (ни в тексте адреса, ни
# через `city_hint`) — т.е. итоговый город результата определил провайдер
@ -88,9 +88,10 @@ def is_within_ekb_bbox_wide(lat: float, lon: float) -> bool:
# bbox = (lat_min, lat_max, lon_min, lon_max).
OBLAST66_BBOX = (55.8, 62.2, 56.7, 66.6)
# Nominatim/Yandex viewport для областного (не только ЕКБ) поиска — тот же
# формат что EKB_BBOX ниже, но покрывает всю область. viewbox — Nominatim
# (lon1,lat1,lon2,lat2, NW→SE); ll/spn — Yandex центр+охват (lon,lat).
# 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",
@ -104,10 +105,9 @@ def is_within_oblast66_bbox(lat: float, lon: float) -> bool:
# Крупные города области (кроме ЕКБ) — НЕ исчерпывающий список (в области ~30
# городов), покрывает основные региональные центры. Используется чтобы:
# (a) не навязывать "Екатеринбург, " в запрос, если пользователь уже указал
# другой город региона (геокодер forward/suggest prefix-логика);
# (b) как known-locality set в _yandex_reverse при перестановке "улица, город".
# городов), покрывает основные региональные центры. Используется чтобы не
# навязывать "Екатеринбург, " в запрос, если пользователь уже указал другой
# город региона (геокодер forward/suggest prefix-логика).
SVERDLOVSK_OBLAST_CITIES = frozenset(
{
"екатеринбург",
@ -198,8 +198,8 @@ def _has_oblast_marker(text_lower: str) -> bool:
def _resolve_city_for_geocode(address: str, city_hint: str | None) -> tuple[str | None, bool]:
"""Определяет, какой город подставлять в запрос внешнему провайдеру (Yandex/
Nominatim), когда сам текст адреса города не называет.
"""Определяет, какой город подставлять в запрос внешнему провайдеру
(Nominatim), когда сам текст адреса города не называет.
Приоритет:
1. Адрес уже содержит маркер города/области региона 66 (`_has_oblast_marker`)
@ -211,8 +211,8 @@ def _resolve_city_for_geocode(address: str, city_hint: str | None) -> tuple[str
для жителей других городов области это давало уверенно неверную цену
(«Ленина, 1» в Нижнем Тагиле снапалось на екатеринбургскую улицу Ленина,
обе улицы называются одинаково). Теперь НЕ подставляем никакой город
провайдер ищет по OBLAST66 viewbox/bbox (см. `_yandex_bias`,
`OBLAST66_VIEWBOX`), без привязки к конкретному городу. Возвращает
провайдер ищет по OBLAST66 viewbox/bbox (см. `OBLAST66_VIEWBOX`), без
привязки к конкретному городу. Возвращает
(None, False) второй элемент False сигнализирует, что город
пользователь НЕ указывал (источник `GeocodeResult.city_ambiguous`).
@ -227,23 +227,6 @@ def _resolve_city_for_geocode(address: str, city_hint: str | None) -> tuple[str
return None, False
def _yandex_bias(address: str, city_hint: str | None) -> dict[str, str]:
"""ll/spn soft-bias для Yandex Geocoder.
ЕКБ-центр (`EKB_BBOX`) ТОЛЬКО если контекст однозначно про Екатеринбург
(явное слово в адресе либо `city_hint`). Иначе центр всей области
(`OBLAST66_VIEWBOX`): раньше bias всегда указывал на ЕКБ независимо от
того, назвал ли пользователь город (#2576) — молчаливый перекос в пользу
ЕКБ даже без текстового префикса "Екатеринбург, ".
"""
normalized = " ".join(address.lower().split())
if _EKATERINBURG_RE.search(normalized):
return EKB_BBOX
if city_hint and _EKATERINBURG_RE.search(" ".join(city_hint.lower().split())):
return EKB_BBOX
return OBLAST66_VIEWBOX
# Города региона 66 КРОМЕ Екатеринбурга — используется чтобы отсечь EKB-only
# локальные тиры (geoportal/cadastral, см. `geocode()`) от адреса другого
# города области. re.escape на элементах SVERDLOVSK_OBLAST_CITIES-{ekb}.
@ -585,8 +568,8 @@ async def _nominatim_lookup(address: str, city_hint: str | None = None) -> Geoco
(предпочитает tight-ЕКБ bbox), который для одноимённых улиц ВНУТРИ региона
(напр. "Ленина" и в Екатеринбурге, и в с. Свердловское) не различает город.
Эмпирически подтверждено: "Ленина 1" без города случайное село внутри
области; "Нижний Тагил, Ленина 1" корректно резолвится. Раз Yandex-ключ
сейчас недействителен (#2585), это единственный живой внешний провайдер
области; "Нижний Тагил, Ленина 1" корректно резолвится. Nominatim
единственный живой внешний провайдер (#2593: Yandex Geocoder удалён)
city_hint должен реально влиять на его результат, не только на кэш-ключ.
"""
headers = {
@ -625,140 +608,7 @@ async def _nominatim_lookup(address: str, city_hint: str | None = None) -> Geoco
)
def _yandex_region_ok(geo_object: dict) -> bool | None:
"""Кросс-чек региона по Yandex `AdministrativeAreaName`.
True/False если Yandex вернул поле и оно однозначно про/не про Свердловскую
область. None если поля нет / оно не строка тогда accept-логика падает
обратно на bbox. Ловит Тюмень/Шадринск/Кунгур/Снежинск они внутри
генерального OBLAST66_BBOX (специально щедрого), но их регион явно другой.
"""
try:
admin_area = (
geo_object.get("metaDataProperty", {})
.get("GeocoderMetaData", {})
.get("AddressDetails", {})
.get("Country", {})
.get("AdministrativeArea", {})
.get("AdministrativeAreaName")
)
except AttributeError:
return None
if not isinstance(admin_area, str) or not admin_area:
return None
return "свердловск" in admin_area.lower()
# ── Provider: Yandex Geocoder (требует key, лучшее покрытие РФ) ─────────────
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=8))
async def _yandex_lookup(
address: str, api_key: str, city_hint: str | None = None
) -> GeocodeResult | None:
"""Yandex Geocoder — 25K req/day free для самопод, лучше РФ.
Docs: https://yandex.ru/dev/maps/geocoder/doc/desc/concepts/input_params.html
Запрашиваем с ll+spn (центр ЕКБ, если контекст ЕКБ, иначе центр всей
области см. `_yandex_bias`) для приоритизации местных результатов, но
БЕЗ rspn чтобы fuzzy matching работал при опечатках.
"""
# Город в запрос подставляем ТОЛЬКО если он известен (адрес уже называет
# город/область региона 66, либо явный `city_hint`) — раньше (#2576) сюда
# молча подставлялся "Екатеринбург" при отсутствии обоих, что давало
# уверенно неверную цену жителям других городов области.
city, _ = _resolve_city_for_geocode(address, city_hint)
geocode_query = f"{city}, {address}" if city else address
bias = _yandex_bias(address, city_hint)
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get(
"https://geocode-maps.yandex.ru/1.x/",
params={
"apikey": api_key,
"geocode": geocode_query,
"format": "json",
"results": 5, # берем top-5, отфильтруем по ЕКБ bbox ниже
"lang": "ru_RU",
"ll": bias["ll"],
"spn": bias["spn"],
},
)
response.raise_for_status()
data = response.json()
members = data.get("response", {}).get("GeoObjectCollection", {}).get("featureMember", [])
if not members:
return None
# Фильтруем top-5 по bbox области (region 66) — игнорируем Челябинск/Уфу/Москву
# при опечатке, но принимаем легитимные Нижний Тагил/Серов/etc. Two-pass
# tie-break: tight-ЕКБ приоритетнее (byte-identical для ЕКБ-запросов, даже
# если Yandex ранжировал его не первым), иначе первый oblast-bbox кандидат.
# Region cross-check (AdministrativeAreaName) отсекает кандидатов ЯВНО из
# другого региона (Тюмень и т.п. попадают в генеральный bbox координатно).
best = None
oblast_fallback = None
for m in members:
obj = m.get("GeoObject", {})
try:
lon_str, lat_str = obj["Point"]["pos"].split()
lat_f, lon_f = float(lat_str), float(lon_str)
except Exception:
continue
if _yandex_region_ok(obj) is False:
continue # регион явно не Свердловская область — не рассматриваем
if is_within_ekb_bbox(lat_f, lon_f):
best = obj
break
if oblast_fallback is None and is_within_oblast66_bbox(lat_f, lon_f):
oblast_fallback = obj
if best is None:
best = oblast_fallback
if best is None:
# Никто из top-5 не попал ни в tight, ни в oblast bbox (или все явно
# отсеяны по региону) → берём первый «как есть», НО только если его
# регион не опровергнут явно — иначе весь смысл region cross-check
# теряется на garbage-query, где top-1 — гарантированно не тот регион
# (например, Тюмень).
first_obj = members[0]["GeoObject"]
if _yandex_region_ok(first_obj) is not False:
best = first_obj
if best is None:
return None
lon_str, lat_str = best["Point"]["pos"].split()
precision_raw = (
best.get("metaDataProperty", {}).get("GeocoderMetaData", {}).get("precision", "other")
)
confidence_map = {
"exact": "exact",
"number": "exact",
"near": "approximate",
"range": "approximate",
"street": "approximate",
}
return GeocodeResult(
lat=float(lat_str),
lon=float(lon_str),
full_address=best.get("metaDataProperty", {})
.get("GeocoderMetaData", {})
.get("text", address),
provider="yandex",
confidence=confidence_map.get(precision_raw, "approximate"),
)
# ── Suggest (автокомплит) ───────────────────────────────────────────────────
# ЕКБ bounding box (приблизительно): юг 56.65, запад 60.40, север 56.95, восток 60.85
# Это ЕКБ fast-path (Yandex rspn=1 bounded tier + soft-bias ll/spn). Для областного
# охвата (region 66) — см. OBLAST66_VIEWBOX выше.
EKB_BBOX = {
"viewbox": "60.40,56.95,60.85,56.65", # Nominatim format: lon1,lat1,lon2,lat2 (NW,SE)
"ll": "60.605,56.838", # Yandex center (lon,lat)
"spn": "0.45,0.30", # Yandex span (lon,lat)
}
@dataclass(frozen=True, slots=True)
@ -769,46 +619,14 @@ class GeocodeSuggestion:
lon: float
kind: str # 'house' / 'street' / 'locality'
# ГАР OBJECTGUID (ФИАС) дома — заполняется ТОЛЬКО для house-level кандидатов
# DaData-тира (fias_level 8/9). У Yandex/Nominatim/cadastral-тиров и у
# DaData-тира (fias_level 8/9). У Nominatim/cadastral-тиров и у
# street/locality-кандидатов остаётся None (нет стабильного house-fias).
fias_id: str | None = None
def _parse_yandex_members(members: list[dict]) -> list[GeocodeSuggestion]:
"""Yandex geocode_members → list[GeocodeSuggestion]. Чистим описание от мусора."""
out: list[GeocodeSuggestion] = []
for m in members:
obj = m.get("GeoObject", {})
try:
lon_str, lat_str = obj["Point"]["pos"].split()
meta = obj.get("metaDataProperty", {}).get("GeocoderMetaData", {})
kind = meta.get("kind", "other")
full = meta.get("text", obj.get("name", ""))
name = obj.get("name", full)
desc = obj.get("description", "")
desc_parts = [
p.strip()
for p in desc.split(",")
if p.strip() and p.strip() not in {"Россия", "Свердловская область"}
]
label = name if not desc_parts else f"{name} · {', '.join(desc_parts)}"
out.append(
GeocodeSuggestion(
label=label,
full_address=full,
lat=float(lat_str),
lon=float(lon_str),
kind=kind,
)
)
except Exception:
continue
return out
# ── DaData suggest wrapper ──────────────────────────────────────────────────
# Маппинг DaData kind → GeocodeSuggestion.kind (внутри geocoder используется
# 'locality' вместо 'city' — consistent с Yandex/Nominatim ветками).
# 'locality' вместо 'city' — consistent с Nominatim-веткой).
_DADATA_KIND_MAP = {"house": "house", "street": "street", "city": "locality"}
@ -847,101 +665,6 @@ async def _dadata_suggest(query: str, limit: int = 8) -> list[GeocodeSuggestion]
return out
async def _yandex_geocode_request(
client: httpx.AsyncClient,
api_key: str,
query: str,
limit: int,
bounded: bool,
bias: dict[str, str] | None = None,
) -> list[dict]:
"""Single Yandex Geocoder request — bounded=True → строго внутри `bias` bbox через rspn=1.
`bias` ll/spn (`EKB_BBOX` или `OBLAST66_VIEWBOX`). По умолчанию `EKB_BBOX`
(backward-compat для вызовов без явного bias).
"""
b = bias or EKB_BBOX
params: dict[str, str] = {
"apikey": api_key,
"geocode": query,
"format": "json",
"results": str(limit),
"lang": "ru_RU",
"ll": b["ll"],
"spn": b["spn"],
}
if bounded:
params["rspn"] = "1"
response = await client.get("https://geocode-maps.yandex.ru/1.x/", params=params)
response.raise_for_status()
data = response.json()
return data.get("response", {}).get("GeoObjectCollection", {}).get("featureMember", [])
@retry(stop=stop_after_attempt(2), wait=wait_exponential(multiplier=1, min=1, max=4))
async def _yandex_suggest(
query: str, api_key: str, limit: int = 8, city_hint: str | None = None
) -> list[GeocodeSuggestion]:
"""Yandex Geocoder с авто-fallback на typo-tolerant режим.
Tier 1: bounded (rspn=1) быстрый путь. Bounded на ЕКБ, если контекст
однозначно про ЕКБ (текст адреса/`city_hint`), иначе bounded на ВСЮ область
(`OBLAST66_VIEWBOX`) раньше (#2576) Tier 1/2 всегда форсили bounded-ЕКБ
с "Екатеринбург, "-префиксом даже когда пользователь не называл город, из-за
чего автокомплит для жителей других городов области либо не находил ничего,
либо подсовывал ЕКБ-варианты вместо нужного города.
Tier 2: bounded на typo-variants (удвоение согласных), тот же bias.
Tier 3: без rspn fuzzy по всей стране, фильтр результатов по bbox области
(region 66) ловит легитимные Нижний Тагил/Серов/etc, которые Tier 1/2
(bounded) структурно вернуть не могут при неверном bias.
"""
city, _ = _resolve_city_for_geocode(query, city_hint)
prefixed_query = f"{city}, {query}" if city else query
bias = _yandex_bias(query, city_hint)
async with httpx.AsyncClient(timeout=8.0) as client:
# Tier 1: strict bounded на оригинал
members = await _yandex_geocode_request(
client,
api_key,
prefixed_query,
limit,
bounded=True,
bias=bias,
)
results = _parse_yandex_members(members)
if results:
return results
# Tier 2: bounded на typo-варианты (тот же bias)
for variant in _typo_variants(query, limit=4):
variant_city, _ = _resolve_city_for_geocode(variant, city_hint)
variant_query = f"{variant_city}, {variant}" if variant_city else variant
members = await _yandex_geocode_request(
client,
api_key,
variant_query,
limit,
bounded=True,
bias=bias,
)
results = _parse_yandex_members(members)
if results:
return results
# Tier 3: без rspn — даём fuzzy сделать своё дело, фильтр по bbox области
members = await _yandex_geocode_request(
client,
api_key,
prefixed_query,
limit,
bounded=False,
bias=bias,
)
results = _parse_yandex_members(members)
in_oblast = [r for r in results if is_within_oblast66_bbox(r.lat, r.lon)]
return in_oblast
async def _nominatim_query_multi(client: httpx.AsyncClient, query: str, limit: int) -> list[dict]:
"""Один Nominatim search с фильтром по bbox области (region 66). Возвращает up to N items."""
response = await client.get(
@ -1024,7 +747,8 @@ async def _nominatim_query_city_aware(
async def _nominatim_suggest(
query: str, limit: int = 8, city_hint: str | None = None
) -> list[GeocodeSuggestion]:
"""Nominatim в режиме suggest. С typo-fallback (для случаев когда Yandex недоступен).
"""Nominatim в режиме suggest. С typo-fallback (для случаев когда оригинальный
запрос ничего не находит).
Суффикс города навязывается, только если он известен: адрес уже называет
город/область региона 66, либо передан явный `city_hint`. Если город
@ -1384,16 +1108,17 @@ async def suggest(
query: str, db: Session | None = None, limit: int = 8, city_hint: str | None = None
) -> list[GeocodeSuggestion]:
"""Автокомплит адресов в Свердловской области (region 66; ЕКБ — основной трафик,
остаётся быстрым fast-path). Cadastral FDW DaData Yandex Nominatim [].
остаётся быстрым fast-path). Cadastral FDW DaData Nominatim [].
db: если передан cadastral lookup через gendesign_cad_buildings (первый tier).
city_hint: город, если известен вызывающему коду (#2576) — прокидывается в
Yandex/Nominatim тиры, чтобы НЕ подставлять "Екатеринбург" молча, когда
Nominatim-тир, чтобы НЕ подставлять "Екатеринбург" молча, когда
пользователь его не называл. Опционально, backward-compatible (None
прежнее поведение минус молчаливый EKB-дефолт, см. `_resolve_city_for_geocode`).
DaData /suggest (PR Q2) token-only, 10k/день, заменяет Yandex который
заблокирован (1k/день demo limit исчерпан). DaData region-constraint уже
охватывает всю область (не только ЕКБ) city_hint ей не нужен.
DaData /suggest (PR Q2) token-only, 10k/день, лучший fit для РФ-адресов
(#2593: Yandex Geocoder, который был primary external provider до DaData,
удалён). DaData region-constraint уже охватывает всю область (не только
ЕКБ) city_hint ей не нужен.
Без кэша (дешёво, провайдеры толерируют автокомплит-запросы).
"""
if not query or len(query.strip()) < 2:
@ -1405,7 +1130,7 @@ async def suggest(
# незнакомый — структурный маркер (`_names_unrecognized_locality`), ИЛИ
# `city_hint` называет город региона 66, отличный от ЕКБ (#2580 C1 —
# иначе хинт мёртвый параметр для этого тира, см. `_ekb_local_tiers_allowed`
# и `geocode()` ниже — тот же гейт). Внешние тиры (2/3/4 ниже) не гейтим —
# и `geocode()` ниже — тот же гейт). Внешние тиры (2/3 ниже) не гейтим —
# они уже oblast-aware.
if db is not None and _ekb_local_tiers_allowed(query, city_hint):
# 1a. Anchored house-match: парсим street+house → точный матч по дом-маркеру.
@ -1422,28 +1147,16 @@ async def suggest(
return cad_results
# Tier 2: DaData /suggest — token-only (X-Secret не нужен), 10k/день free,
# лучший fit для РФ адресов. Заменил Yandex как primary external provider
# после того как demo-key Yandex (1k/день) был исчерпан.
# лучший fit для РФ адресов.
if settings.dadata_api_token:
try:
dadata_results = await _dadata_suggest(query, limit)
if dadata_results:
return dadata_results
except Exception:
logger.exception("dadata suggest failed, falling back to yandex")
logger.exception("dadata suggest failed, falling back to nominatim")
# Tier 3: Yandex (legacy — оставляем как fallback, если key есть)
if settings.yandex_geocoder_api_key:
try:
results = await _yandex_suggest(
query, settings.yandex_geocoder_api_key, limit, city_hint=city_hint
)
if results:
return results
except Exception:
logger.exception("yandex suggest failed, falling back to nominatim")
# Tier 4: Nominatim (последний fallback — OSM, без ключа)
# Tier 3: Nominatim (последний fallback — OSM, без ключа)
try:
return await _nominatim_suggest(query, limit, city_hint=city_hint)
except Exception:
@ -1453,15 +1166,15 @@ async def suggest(
# ── Public API ───────────────────────────────────────────────────────────────
async def geocode(address: str, db: Session, city_hint: str | None = None) -> GeocodeResult | None:
"""Геокодинг с кэшем. Cadastral FDW → Yandex → Nominatim → None.
"""Геокодинг с кэшем. Cadastral FDW → Nominatim → None.
Args:
address: пользовательский ввод (может быть грязным нормализуем).
db: сессия Postgres для cache lookup/write и cadastral FDW lookup.
city_hint: город, если известен вызывающему коду (#2576) — например
выбран пользователем на предыдущем шаге UI. Опциональный, не
ломает существующий контракт. Прокидывается в Yandex/Nominatim
внешние тиры вместо молчаливой подстановки "Екатеринбург" и
ломает существующий контракт. Прокидывается в Nominatim-тир
вместо молчаливой подстановки "Екатеринбург" и
участвует в cache-ключе (см. `_cache_key`), чтобы ответы для
разных городов по одному и тому же тексту адреса не перезатирали
друг друга.
@ -1575,42 +1288,7 @@ async def geocode(address: str, db: Session, city_hint: str | None = None) -> Ge
)
return result
# 3. Yandex (если есть key) с typo-fallback
if settings.yandex_geocoder_api_key:
try:
result = await _yandex_lookup(address, settings.yandex_geocoder_api_key, city_hint)
# Если результат вне области (region 66) — пробуем typo-варианты
in_oblast = result is not None and is_within_oblast66_bbox(result.lat, result.lon)
if result is not None and in_oblast:
result = replace(result, city_ambiguous=city_ambiguous)
await asyncio.to_thread(_cache_put, db, addr_norm, result)
logger.info("geocode yandex: %s → (%.5f, %.5f)", addr_norm, result.lat, result.lon)
return result
# Tier 2: typo-variants
for variant in _typo_variants(address, limit=4):
try:
result = await _yandex_lookup(
variant, settings.yandex_geocoder_api_key, city_hint
)
except Exception:
continue
if result is None:
continue
if is_within_oblast66_bbox(result.lat, result.lon):
result = replace(result, city_ambiguous=city_ambiguous)
await asyncio.to_thread(_cache_put, db, addr_norm, result)
logger.info(
"geocode yandex typo-fixed: %s%s → (%.5f, %.5f)",
addr_norm,
variant,
result.lat,
result.lon,
)
return result
except Exception:
logger.exception("yandex geocoder failed, falling back to nominatim")
# 4. Nominatim fallback
# 3. Nominatim fallback
try:
result = await _nominatim_lookup(address, city_hint)
if result is not None:
@ -1628,9 +1306,14 @@ async def geocode(address: str, db: Session, city_hint: str | None = None) -> Ge
# ── Reverse: координаты → адрес (для map-picker'а) ──────────────────────────
# Precision levels which we treat as "снап к зданию имеет смысл":
# - exact — точный матч на здание (Yandex)
# - 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 (то же по точности что Yandex "number")
# - cadastral — Cadastral FDW row (та же точность, что и "number")
# Остальные (street/range/near/locality/other) → не снапаем, marker остаётся на клике.
_SNAP_PRECISIONS = {"exact", "number", "cadastral"}
@ -1643,14 +1326,14 @@ class ReverseGeocodeResult:
- `snapped_lat` координата центра здания если provider дал её,
иначе echo `lat` входной точки (для precision=street/locality).
- `snapped_lon` то же.
- `precision` yandex-style: `exact`/`number`/`street`/`range`/`near`/
`locality`/`other`/`cadastral`. Используется фронтом чтобы
- `precision` `exact`/`number`/`street`/`range`/`near`/`locality`/
`other`/`cadastral`. Используется фронтом чтобы
решить двигать marker (exact/number) или нет.
- `provider` кто дал результат (`yandex`/`nominatim`/`cadastral`).
- `provider` кто дал результат (`nominatim`/`cadastral`).
Фронт MapPicker'а после клика смотрит на precision: если `exact`/`number`
и snapped >5m от click point пересаживает marker на snapped point
(чтобы пользователь видел центр дома по Яндексу, а не свой клик во дворе).
(чтобы пользователь видел центр дома, а не свой клик во дворе).
Для остальных precision marker остаётся где кликнули не врём что нашли
точное здание.
"""
@ -1659,7 +1342,7 @@ class ReverseGeocodeResult:
snapped_lat: float
snapped_lon: float
precision: str
provider: Literal["yandex", "nominatim", "cadastral"]
provider: Literal["nominatim", "cadastral"]
def _format_reverse_address(addr: dict) -> str | None:
@ -1686,77 +1369,6 @@ def _format_reverse_address(addr: dict) -> str | None:
return ", ".join(parts)
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=8))
async def _yandex_reverse(lat: float, lon: float, api_key: str) -> ReverseGeocodeResult | None:
"""Yandex Geocoder /reverse — возвращает snapped Point.pos здания + precision.
Docs: https://yandex.ru/dev/maps/geocoder/doc/desc/concepts/input_params.html
Параметр `geocode` принимает `lon,lat` (важно обратный порядок!).
"""
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get(
"https://geocode-maps.yandex.ru/1.x/",
params={
"apikey": api_key,
"geocode": f"{lon},{lat}",
"format": "json",
"results": "1",
"kind": "house", # просим именно здание (house), не улицу
"lang": "ru_RU",
},
)
response.raise_for_status()
data = response.json()
members = data.get("response", {}).get("GeoObjectCollection", {}).get("featureMember", [])
if not members:
return None
obj = members[0].get("GeoObject", {})
try:
lon_str, lat_str = obj["Point"]["pos"].split()
snapped_lat = float(lat_str)
snapped_lon = float(lon_str)
except (KeyError, ValueError):
return None
meta = obj.get("metaDataProperty", {}).get("GeocoderMetaData", {})
precision = str(meta.get("precision", "other"))
address_text = str(meta.get("text") or obj.get("name") or "")
# Yandex address text начинается с «Россия, Свердловская область, …» — режем prefix,
# оставляем «улица, дом, город» для consistency с Nominatim/cadastral.
if address_text:
# «Россия, Свердловская область, Екатеринбург, улица Малышева, 51»
# → «улица Малышева, 51, Екатеринбург» (drop country/oblast, swap city/street)
parts = [p.strip() for p in address_text.split(",") if p.strip()]
filtered = [
p
for p in parts
if p not in {"Россия", "Свердловская область"} and not p.startswith("городской округ")
]
# Найдём locality (Екатеринбург / Нижний Тагил / Берёзовский / …) и переставим
# в конец — любой город региона 66, не только ЕКБ (case-insensitive match).
locality = None
rest: list[str] = []
for p in filtered:
if locality is None and p.lower() in SVERDLOVSK_OBLAST_CITIES:
locality = p
else:
rest.append(p)
if locality and rest:
address_text = ", ".join([*rest, locality])
else:
address_text = ", ".join(filtered)
if not address_text:
return None
return ReverseGeocodeResult(
address=address_text,
snapped_lat=snapped_lat,
snapped_lon=snapped_lon,
precision=precision,
provider="yandex",
)
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=8))
async def _nominatim_reverse(lat: float, lon: float) -> ReverseGeocodeResult | None:
"""Nominatim /reverse → ReverseGeocodeResult с snapped coords из item.lat/lon.
@ -1856,7 +1468,7 @@ def _cadastral_reverse_sync_full(
async def reverse_geocode(
lat: float, lon: float, db: Session | None = None
) -> ReverseGeocodeResult | None:
"""Cadastral FDW → Yandex (если key) → Nominatim. Возвращает snapped coords.
"""Cadastral FDW → Nominatim. Возвращает snapped coords.
Возвращает None если ни один источник не дал адрес. Endpoint
api/v1/geocode/reverse сам выкинет 404. НЕ даёт выйти HTTPStatusError
@ -1881,16 +1493,7 @@ async def reverse_geocode(
provider="cadastral",
)
# 2. Yandex — основной источник snap'а (его Point.pos = центр здания)
if settings.yandex_geocoder_api_key:
try:
result = await _yandex_reverse(lat, lon, settings.yandex_geocoder_api_key)
if result is not None:
return result
except Exception:
logger.exception("yandex reverse failed for (%.5f, %.5f)", lat, lon)
# 3. Nominatim fallback (wrap to prevent 500 on ban/rate-limit)
# 2. Nominatim fallback (wrap to prevent 500 on ban/rate-limit)
try:
return await _nominatim_reverse(lat, lon)
except Exception:

View file

@ -6,7 +6,7 @@
wired into in-app scheduler, window 06:00-09:00 UTC.
Pattern: dedup по address (1 unique address 1 geocode call UPDATE all listings).
Rate limit: Nominatim 1 req/sec. Yandex 25K/day если YANDEX_GEOCODER_API_KEY set.
Rate limit: Nominatim 1 req/sec (#2593: Yandex Geocoder tier удалён из geocoder).
Отличие от /admin/geocode-missing (per-ID):
- Этот модуль группирует по address меньше API calls (dedup).

View file

@ -176,24 +176,18 @@ def _nom_result(address: str) -> ReverseGeocodeResult:
async def test_reverse_geocode_prefers_cadastral_over_nominatim() -> None:
"""Cadastral returns address → Yandex/Nominatim never called."""
"""Cadastral returns address → Nominatim never called."""
db = MagicMock()
with (
patch(
"app.services.geocoder._cadastral_reverse_sync_full",
return_value=("ул. Малышева, 30, Екатеринбург", 56.8385, 60.6052),
) as mock_cad,
patch("app.services.geocoder.settings") as mock_settings,
patch(
"app.services.geocoder._yandex_reverse",
new_callable=AsyncMock,
) as mock_yandex,
patch(
"app.services.geocoder._nominatim_reverse",
new_callable=AsyncMock,
) as mock_nom,
):
mock_settings.yandex_geocoder_api_key = "fake-key"
result = await reverse_geocode(56.838, 60.605, db=db)
assert result is not None
@ -203,41 +197,6 @@ async def test_reverse_geocode_prefers_cadastral_over_nominatim() -> None:
assert result.precision == "number"
assert result.provider == "cadastral"
mock_cad.assert_called_once_with(db, 56.838, 60.605)
mock_yandex.assert_not_called()
mock_nom.assert_not_called()
async def test_reverse_geocode_uses_yandex_when_cadastral_empty() -> None:
"""Cadastral None + Yandex key set → Yandex called, Nominatim skipped."""
db = MagicMock()
yandex_result = ReverseGeocodeResult(
address="улица Малышева, 51, Екатеринбург",
snapped_lat=56.838004,
snapped_lon=60.586155,
precision="exact",
provider="yandex",
)
with (
patch("app.services.geocoder._cadastral_reverse_sync_full", return_value=None),
patch("app.services.geocoder.settings") as mock_settings,
patch(
"app.services.geocoder._yandex_reverse",
new_callable=AsyncMock,
return_value=yandex_result,
) as mock_yandex,
patch(
"app.services.geocoder._nominatim_reverse",
new_callable=AsyncMock,
) as mock_nom,
):
mock_settings.yandex_geocoder_api_key = "fake-key"
result = await reverse_geocode(56.838, 60.586, db=db)
assert result is not None
assert result.provider == "yandex"
assert result.precision == "exact"
assert result.snapped_lat == 56.838004
mock_yandex.assert_called_once()
mock_nom.assert_not_called()
@ -263,23 +222,16 @@ async def test_reverse_geocode_falls_back_to_nominatim_when_cadastral_none() ->
async def test_reverse_geocode_returns_none_when_all_providers_fail() -> None:
"""Cadastral + Yandex + Nominatim — все падают → returns None, no exception."""
"""Cadastral + Nominatim — все падают → returns None, no exception."""
db = MagicMock()
with (
patch("app.services.geocoder._cadastral_reverse_sync_full", return_value=None),
patch("app.services.geocoder.settings") as mock_settings,
patch(
"app.services.geocoder._yandex_reverse",
new_callable=AsyncMock,
side_effect=RuntimeError("Yandex 429 rate-limited"),
),
patch(
"app.services.geocoder._nominatim_reverse",
new_callable=AsyncMock,
side_effect=RuntimeError("Nominatim 403 Forbidden — IP banned"),
),
):
mock_settings.yandex_geocoder_api_key = "fake-key"
result = await reverse_geocode(56.838, 60.605, db=db)
# Must return None, not raise
@ -287,19 +239,17 @@ async def test_reverse_geocode_returns_none_when_all_providers_fail() -> None:
async def test_reverse_geocode_without_db_skips_cadastral() -> None:
"""When db=None, cadastral tier is skipped entirely → goes straight to Yandex/Nominatim."""
"""When db=None, cadastral tier is skipped entirely → goes straight to Nominatim."""
with (
patch(
"app.services.geocoder._cadastral_reverse_sync_full",
) as mock_cad,
patch("app.services.geocoder.settings") as mock_settings,
patch(
"app.services.geocoder._nominatim_reverse",
new_callable=AsyncMock,
return_value=_nom_result("ул. Ленина, 1, Екатеринбург"),
),
):
mock_settings.yandex_geocoder_api_key = None
result = await reverse_geocode(56.838, 60.605, db=None)
mock_cad.assert_not_called()
@ -307,36 +257,11 @@ async def test_reverse_geocode_without_db_skips_cadastral() -> None:
assert result.address == "ул. Ленина, 1, Екатеринбург"
async def test_reverse_geocode_falls_through_yandex_to_nominatim_on_exception() -> None:
"""Yandex raises → Nominatim still tried."""
db = MagicMock()
with (
patch("app.services.geocoder._cadastral_reverse_sync_full", return_value=None),
patch("app.services.geocoder.settings") as mock_settings,
patch(
"app.services.geocoder._yandex_reverse",
new_callable=AsyncMock,
side_effect=RuntimeError("Yandex 500"),
),
patch(
"app.services.geocoder._nominatim_reverse",
new_callable=AsyncMock,
return_value=_nom_result("ул. Дублёр, 1"),
) as mock_nom,
):
mock_settings.yandex_geocoder_api_key = "fake-key"
result = await reverse_geocode(56.838, 60.605, db=db)
assert result is not None
assert result.provider == "nominatim"
mock_nom.assert_called_once()
# ── geocode: cadastral as first tier ─────────────────────────────────────────
async def test_geocode_uses_cadastral_before_yandex() -> None:
"""Cadastral FDW returns suggestion → geocode returns it without hitting Yandex."""
async def test_geocode_uses_cadastral_before_nominatim() -> None:
"""Cadastral FDW returns suggestion → geocode returns it without hitting Nominatim."""
db = MagicMock()
# Cache miss
db.execute.side_effect = [
@ -362,7 +287,7 @@ async def test_geocode_uses_cadastral_before_yandex() -> None:
return_value=[cad_suggestion],
) as mock_cad,
patch("app.services.geocoder._cache_put"),
patch("app.services.geocoder._yandex_lookup", new_callable=AsyncMock) as mock_yandex,
patch("app.services.geocoder._nominatim_lookup", new_callable=AsyncMock) as mock_nominatim,
):
result = await geocode("Хохрякова 81", db)
@ -371,14 +296,14 @@ async def test_geocode_uses_cadastral_before_yandex() -> None:
assert result.lon == 60.610
assert result.confidence == "exact"
mock_cad.assert_called_once()
mock_yandex.assert_not_called()
mock_nominatim.assert_not_called()
# ── suggest: cadastral as first tier ─────────────────────────────────────────
async def test_suggest_uses_cadastral_before_yandex() -> None:
"""Cadastral FDW returns results → suggest returns them without hitting Yandex."""
async def test_suggest_uses_cadastral_before_nominatim() -> None:
"""Cadastral FDW returns results → suggest returns them without hitting Nominatim."""
db = MagicMock()
cad_suggestions = [
GeocodeSuggestion(
@ -396,53 +321,20 @@ async def test_suggest_uses_cadastral_before_yandex() -> None:
return_value=cad_suggestions,
) as mock_cad,
patch(
"app.services.geocoder._yandex_suggest",
"app.services.geocoder._nominatim_suggest",
new_callable=AsyncMock,
) as mock_yandex,
) as mock_nominatim,
):
results = await suggest("Малышева", db=db, limit=8)
assert len(results) == 1
assert results[0].full_address == "ул. Малышева, 30, Екатеринбург"
mock_cad.assert_called_once()
mock_yandex.assert_not_called()
mock_nominatim.assert_not_called()
async def test_suggest_falls_back_to_yandex_when_cadastral_empty() -> None:
"""Cadastral returns [] → DaData not configured → falls through to Yandex."""
db = MagicMock()
yandex_result = GeocodeSuggestion(
label="Малышева · Октябрьский",
full_address="улица Малышева, Екатеринбург",
lat=56.838,
lon=60.605,
kind="street",
)
with (
patch("app.services.geocoder._cadastral_forward_sync", return_value=[]),
patch("app.services.geocoder.settings") as mock_settings,
patch(
"app.services.geocoder._dadata_suggest",
new_callable=AsyncMock,
return_value=[], # DaData empty → falls through
),
patch(
"app.services.geocoder._yandex_suggest",
new_callable=AsyncMock,
return_value=[yandex_result],
) as mock_yandex,
):
mock_settings.dadata_api_token = "fake-dadata-token" # gate to call _dadata_suggest
mock_settings.yandex_geocoder_api_key = "fake-key"
results = await suggest("Малышева", db=db, limit=8)
assert len(results) == 1
mock_yandex.assert_called_once()
async def test_suggest_uses_dadata_before_yandex_when_cadastral_empty() -> None:
"""Cadastral returns [] → DaData tier returns results → Yandex never called (PR Q2)."""
async def test_suggest_uses_dadata_before_nominatim_when_cadastral_empty() -> None:
"""Cadastral returns [] → DaData tier returns results → Nominatim never called (PR Q2)."""
db = MagicMock()
dadata_result = GeocodeSuggestion(
label="г Екатеринбург, ул Малышева, д 30",
@ -461,18 +353,17 @@ async def test_suggest_uses_dadata_before_yandex_when_cadastral_empty() -> None:
return_value=[dadata_result],
) as mock_dadata,
patch(
"app.services.geocoder._yandex_suggest",
"app.services.geocoder._nominatim_suggest",
new_callable=AsyncMock,
) as mock_yandex,
) as mock_nominatim,
):
mock_settings.dadata_api_token = "fake-dadata-token"
mock_settings.yandex_geocoder_api_key = "fake-yandex-key"
results = await suggest("Малышева", db=db, limit=8)
assert len(results) == 1
assert results[0].lat == 56.838011
mock_dadata.assert_called_once()
mock_yandex.assert_not_called()
mock_nominatim.assert_not_called()
# ── ensure_fdw_user_mapping: SQL injection / whitelist guards ─────────────────

View file

@ -260,7 +260,6 @@ async def test_geocode_uses_geoportal_first() -> None:
patch("app.services.geocoder._cadastral_house_match") as mock_cad,
patch("app.services.geocoder._cadastral_forward_sync") as mock_forward,
patch("app.services.geocoder._cache_put"),
patch("app.services.geocoder._yandex_lookup", new_callable=AsyncMock) as mock_yandex,
):
result = await geocode("Космонавтов 7б", db)
@ -273,7 +272,6 @@ async def test_geocode_uses_geoportal_first() -> None:
# geoportal hit short-circuits everything downstream
mock_cad.assert_not_called()
mock_forward.assert_not_called()
mock_yandex.assert_not_called()
async def test_geocode_falls_through_to_cadastral_when_geoportal_misses() -> None:
@ -295,7 +293,6 @@ async def test_geocode_falls_through_to_cadastral_when_geoportal_misses() -> Non
patch("app.services.geocoder._cadastral_house_match", return_value=cad_hit) as mock_cad,
patch("app.services.geocoder._cadastral_forward_sync") as mock_forward,
patch("app.services.geocoder._cache_put"),
patch("app.services.geocoder._yandex_lookup", new_callable=AsyncMock) as mock_yandex,
):
result = await geocode("Серова 27", db)
@ -304,7 +301,6 @@ async def test_geocode_falls_through_to_cadastral_when_geoportal_misses() -> Non
mock_geo.assert_called_once()
mock_cad.assert_called_once()
mock_forward.assert_not_called()
mock_yandex.assert_not_called()
async def test_geocode_skips_geoportal_when_parse_fails() -> None:
@ -319,14 +315,12 @@ async def test_geocode_skips_geoportal_when_parse_fails() -> None:
patch("app.services.geocoder._cadastral_house_match") as mock_cad,
patch("app.services.geocoder._cadastral_forward_sync", return_value=[]) as mock_forward,
patch("app.services.geocoder._cache_put"),
patch("app.services.geocoder.settings") as mock_settings,
patch(
"app.services.geocoder._nominatim_lookup",
new_callable=AsyncMock,
return_value=None,
),
):
mock_settings.yandex_geocoder_api_key = None
result = await geocode("полный мусор без дома", db)
assert result is None

View file

@ -6,7 +6,7 @@ Bug context (issue #582 Phase 5):
оставался где user кликнул иногда в проезде / дворе.
Fix: backend теперь возвращает snapped_lat/snapped_lon (центр matched здания
от Yandex/Nominatim/cadastral) + precision, фронт двигает marker если precision
от Nominatim/cadastral) + precision, фронт двигает marker если precision
in (exact, number, cadastral).
"""
@ -46,6 +46,7 @@ def app() -> FastAPI:
# ── Endpoint response shape ──────────────────────────────────────────────────
def test_reverse_endpoint_returns_snapped_fields(app: FastAPI) -> None:
"""Endpoint should include address + lat/lon (echo) + snapped_lat/snapped_lon + precision."""
client = TestClient(app)
@ -54,7 +55,7 @@ def test_reverse_endpoint_returns_snapped_fields(app: FastAPI) -> None:
snapped_lat=56.838004,
snapped_lon=60.586155,
precision="exact",
provider="yandex",
provider="cadastral",
)
with patch(
"app.api.v1.geocode.reverse_geocode",
@ -73,7 +74,7 @@ def test_reverse_endpoint_returns_snapped_fields(app: FastAPI) -> None:
assert body["snapped_lat"] == 56.838004
assert body["snapped_lon"] == 60.586155
assert body["precision"] == "exact"
assert body["provider"] == "yandex"
assert body["provider"] == "cadastral"
def test_reverse_endpoint_404_when_no_address(app: FastAPI) -> None:
@ -113,6 +114,7 @@ def test_reverse_endpoint_street_precision_does_not_lose_snap_fields(app: FastAP
# ── snap_precision_useful helper ─────────────────────────────────────────────
def test_snap_precision_useful_exact_and_number() -> None:
assert snap_precision_useful("exact") is True
assert snap_precision_useful("number") is True
@ -125,104 +127,3 @@ def test_snap_precision_useful_rejects_street_and_other() -> None:
assert snap_precision_useful("locality") is False
assert snap_precision_useful("other") is False
assert snap_precision_useful("") is False
# ── Yandex reverse parsing ───────────────────────────────────────────────────
async def test_yandex_reverse_parses_snapped_point_and_precision() -> None:
"""`_yandex_reverse` извлекает Point.pos (lon lat) и precision из metaDataProperty."""
import httpx
from app.services.geocoder import _yandex_reverse
sample = {
"response": {
"GeoObjectCollection": {
"featureMember": [
{
"GeoObject": {
"metaDataProperty": {
"GeocoderMetaData": {
"precision": "exact",
"text": (
"Россия, Свердловская область, "
"Екатеринбург, улица Малышева, 51"
),
"kind": "house",
}
},
"name": "улица Малышева, 51",
"Point": {"pos": "60.586155 56.838004"},
}
}
]
}
}
}
class _FakeResp:
status_code = 200
def raise_for_status(self) -> None:
return None
def json(self) -> dict:
return sample
class _FakeClient:
async def __aenter__(self) -> _FakeClient:
return self
async def __aexit__(self, *_: object) -> None:
return None
async def get(self, *_: object, **__: object) -> _FakeResp:
return _FakeResp()
with patch.object(httpx, "AsyncClient", lambda *a, **kw: _FakeClient()):
result = await _yandex_reverse(56.8381, 60.5860, api_key="fake")
assert result is not None
# Yandex pos формат: "lon lat" → snapped_lat=56.838004, snapped_lon=60.586155
assert abs(result.snapped_lat - 56.838004) < 1e-6
assert abs(result.snapped_lon - 60.586155) < 1e-6
assert result.precision == "exact"
assert result.provider == "yandex"
# Address text должен быть очищен от "Россия, Свердловская область"
assert "Россия" not in result.address
assert "Свердловская область" not in result.address
assert "Малышева" in result.address
assert "51" in result.address
async def test_yandex_reverse_returns_none_on_empty_results() -> None:
"""Empty featureMember → None."""
import httpx
from app.services.geocoder import _yandex_reverse
sample = {"response": {"GeoObjectCollection": {"featureMember": []}}}
class _FakeResp:
status_code = 200
def raise_for_status(self) -> None:
return None
def json(self) -> dict:
return sample
class _FakeClient:
async def __aenter__(self) -> _FakeClient:
return self
async def __aexit__(self, *_: object) -> None:
return None
async def get(self, *_: object, **__: object) -> _FakeResp:
return _FakeResp()
with patch.object(httpx, "AsyncClient", lambda *a, **kw: _FakeClient()):
result = await _yandex_reverse(56.0, 60.0, api_key="fake")
assert result is None

View file

@ -8,12 +8,10 @@
инвариант OBLAST66 TIGHT, дальние города области, известное ограничение (Тюмень
внутри bbox координатно компенсируется region cross-check на accept-сайтах);
- _has_oblast_marker word-boundary matching (не substring);
- _nominatim_query / _yandex_lookup accept-сайты two-pass tie-break (tight ЕКБ
приоритетнее) + region cross-check (отсекает Тюмень и т.п. даже внутри bbox).
- _nominatim_query accept-сайт two-pass tie-break (tight ЕКБ приоритетнее) +
region cross-check (отсекает Тюмень и т.п. даже внутри bbox).
"""
from unittest.mock import patch
import httpx
import pytest
@ -27,7 +25,6 @@ from app.services.geocoder import (
_names_non_ekb_city,
_names_unrecognized_locality,
_nominatim_query,
_yandex_lookup,
is_within_ekb_bbox,
is_within_ekb_bbox_wide,
is_within_oblast66_bbox,
@ -145,9 +142,9 @@ def test_oblast66_bbox_admits_tyumen_by_design() -> None:
Это ЗНАЕМОЕ и намеренное ограничение самого bbox-хелпера (дешевле
false-positive у границы, чем false-negative на корректном адресе области).
Реальное отсечение Тюмени происходит на accept-сайтах через region
cross-check см. test_nominatim_query_rejects_out_of_region_only_candidate /
test_yandex_lookup_rejects_out_of_region_only_candidate ниже.
Реальное отсечение Тюмени происходит на accept-сайте через region
cross-check см. test_nominatim_query_rejects_out_of_region_only_candidate
ниже.
"""
assert is_within_oblast66_bbox(57.1530, 65.5343) is True
@ -419,66 +416,3 @@ async def test_nominatim_query_accepts_oblast_wide_when_region_absent() -> None:
_REAL_ASYNC_CLIENT = httpx.AsyncClient
def _yandex_client_factory(transport: httpx.MockTransport):
"""Drop-in replacement для httpx.AsyncClient внутри geocoder._yandex_lookup."""
def factory(*_: object, **__: object) -> httpx.AsyncClient:
return _REAL_ASYNC_CLIENT(transport=transport)
return factory
def _yandex_geo_object(lat: str, lon: str, admin_area: str | None) -> dict:
meta: dict = {"precision": "exact", "text": "тест"}
if admin_area is not None:
meta["AddressDetails"] = {
"Country": {"AdministrativeArea": {"AdministrativeAreaName": admin_area}}
}
return {
"GeoObject": {
"Point": {"pos": f"{lon} {lat}"},
"metaDataProperty": {"GeocoderMetaData": meta},
}
}
def _yandex_payload(members: list[dict]) -> dict:
return {"response": {"GeoObjectCollection": {"featureMember": members}}}
async def test_yandex_lookup_prefers_tight_ekb_over_oblast_rank0() -> None:
"""Тот же tie-break сценарий, что и для Nominatim, но для Yandex top-5."""
members = [
_yandex_geo_object("59.6297", "60.5541", "Свердловская область"), # Серов, rank0
_yandex_geo_object("56.838", "60.605", "Свердловская область"), # ЕКБ tight, rank1
]
payload = _yandex_payload(members)
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, json=payload)
transport = httpx.MockTransport(handler)
with patch("app.services.geocoder.httpx.AsyncClient", _yandex_client_factory(transport)):
result = await _yandex_lookup("Серова 27", "fake-key")
assert result is not None
assert result.lat == pytest.approx(56.838)
assert result.lon == pytest.approx(60.605)
async def test_yandex_lookup_rejects_out_of_region_only_candidate() -> None:
"""Единственный кандидат — Тюмень (bbox admits координатно, регион другой) →
region cross-check отсекает даже в ultimate as-is fallback None."""
members = [_yandex_geo_object("57.1522", "65.5272", "Тюменская область")]
payload = _yandex_payload(members)
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, json=payload)
transport = httpx.MockTransport(handler)
with patch("app.services.geocoder.httpx.AsyncClient", _yandex_client_factory(transport)):
result = await _yandex_lookup("какой-то мусорный адрес", "fake-key")
assert result is None

View file

@ -188,7 +188,6 @@ async def test_geocode_uses_house_match_before_legacy_forward() -> None:
"app.services.geocoder._cadastral_forward_sync",
) as mock_forward,
patch("app.services.geocoder._cache_put"),
patch("app.services.geocoder._yandex_lookup", new_callable=AsyncMock) as mock_yandex,
):
result = await geocode("Серова 27", db)
@ -199,7 +198,6 @@ async def test_geocode_uses_house_match_before_legacy_forward() -> None:
mock_house.assert_called_once()
# house-match hit → legacy raw-ILIKE forward never invoked
mock_forward.assert_not_called()
mock_yandex.assert_not_called()
async def test_geocode_falls_back_to_legacy_forward_when_house_match_misses() -> None:
@ -225,7 +223,6 @@ async def test_geocode_falls_back_to_legacy_forward_when_house_match_misses() ->
return_value=[legacy_hit],
) as mock_forward,
patch("app.services.geocoder._cache_put"),
patch("app.services.geocoder._yandex_lookup", new_callable=AsyncMock) as mock_yandex,
):
result = await geocode("Серова 27", db)
@ -233,7 +230,6 @@ async def test_geocode_falls_back_to_legacy_forward_when_house_match_misses() ->
assert result.lat == 56.84
mock_house.assert_called_once()
mock_forward.assert_called_once()
mock_yandex.assert_not_called()
async def test_geocode_skips_house_match_when_parse_fails() -> None:
@ -250,14 +246,12 @@ async def test_geocode_skips_house_match_when_parse_fails() -> None:
return_value=[],
) as mock_forward,
patch("app.services.geocoder._cache_put"),
patch("app.services.geocoder.settings") as mock_settings,
patch(
"app.services.geocoder._nominatim_lookup",
new_callable=AsyncMock,
return_value=None,
),
):
mock_settings.yandex_geocoder_api_key = None
result = await geocode("полный мусор без дома", db)
assert result is None
@ -290,14 +284,12 @@ async def test_geocode_skips_ekb_local_tiers_for_non_ekb_city() -> None:
patch("app.services.geocoder._cadastral_house_match") as mock_house,
patch("app.services.geocoder._cadastral_forward_sync") as mock_forward,
patch("app.services.geocoder._cache_put"),
patch("app.services.geocoder.settings") as mock_settings,
patch(
"app.services.geocoder._nominatim_lookup",
new_callable=AsyncMock,
return_value=nominatim_result,
) as mock_nominatim,
):
mock_settings.yandex_geocoder_api_key = None
result = await geocode("Нижний Тагил, проспект Ленина, 1", db)
assert result is not None
@ -353,14 +345,12 @@ async def test_geocode_skips_ekb_local_tiers_for_unrecognized_locality(
patch("app.services.geocoder._cadastral_house_match") as mock_house,
patch("app.services.geocoder._cadastral_forward_sync") as mock_forward,
patch("app.services.geocoder._cache_put"),
patch("app.services.geocoder.settings") as mock_settings,
patch(
"app.services.geocoder._nominatim_lookup",
new_callable=AsyncMock,
return_value=nominatim_result,
) as mock_nominatim,
):
mock_settings.yandex_geocoder_api_key = None
result = await geocode(address, db)
assert result is not None, label
@ -462,14 +452,12 @@ async def test_geocode_city_hint_unknown_city_skips_local_ekb_tiers() -> None:
) as mock_geoportal,
patch("app.services.geocoder._cadastral_house_match", return_value=ekb_hit) as mock_house,
patch("app.services.geocoder._cadastral_forward_sync", return_value=[]),
patch("app.services.geocoder.settings") as mock_settings,
patch(
"app.services.geocoder._nominatim_lookup",
new_callable=AsyncMock,
return_value=rezh_result,
),
):
mock_settings.yandex_geocoder_api_key = None
result = await geocode("Ленина, 1", db, city_hint="Реж")
mock_geoportal.assert_not_called()
@ -501,7 +489,6 @@ async def test_suggest_uses_house_match_before_legacy_forward() -> None:
patch(
"app.services.geocoder._cadastral_forward_sync",
) as mock_forward,
patch("app.services.geocoder._yandex_suggest", new_callable=AsyncMock) as mock_yandex,
):
results = await suggest("Серова 27", db=db, limit=8)
@ -509,7 +496,6 @@ async def test_suggest_uses_house_match_before_legacy_forward() -> None:
assert results[0].lat == 56.81188
mock_house.assert_called_once()
mock_forward.assert_not_called()
mock_yandex.assert_not_called()
async def test_suggest_falls_back_to_legacy_forward_when_house_match_misses() -> None:
@ -534,7 +520,6 @@ async def test_suggest_falls_back_to_legacy_forward_when_house_match_misses() ->
"app.services.geocoder._cadastral_forward_sync",
return_value=legacy,
) as mock_forward,
patch("app.services.geocoder._yandex_suggest", new_callable=AsyncMock) as mock_yandex,
):
results = await suggest("Серова 27", db=db, limit=8)
@ -542,7 +527,6 @@ async def test_suggest_falls_back_to_legacy_forward_when_house_match_misses() ->
assert results[0].lat == 56.84
mock_house.assert_called_once()
mock_forward.assert_called_once()
mock_yandex.assert_not_called()
async def test_suggest_skips_ekb_local_tier_for_non_ekb_city() -> None:
@ -571,7 +555,6 @@ async def test_suggest_skips_ekb_local_tier_for_non_ekb_city() -> None:
) as mock_nominatim,
):
mock_settings.dadata_api_token = None
mock_settings.yandex_geocoder_api_key = None
results = await suggest("Нижний Тагил, проспект Ленина, 1", db=db, limit=8)
assert len(results) == 1
@ -615,7 +598,6 @@ async def test_suggest_skips_ekb_local_tier_for_unrecognized_locality(
) as mock_nominatim,
):
mock_settings.dadata_api_token = None
mock_settings.yandex_geocoder_api_key = None
results = await suggest(query, db=db, limit=8)
assert len(results) == 1, label

View file

@ -1,18 +1,20 @@
"""Тесты #2576 — geocoder больше НЕ подставляет "Екатеринбург" молча.
Проблема (issue #2576 / эпик расширения на область): `_yandex_lookup`,
`_yandex_suggest`, `_nominatim_suggest` при отсутствии маркера города/области в
самом адресе всегда молча подставляли "Екатеринбург" житель Нижнего Тагила,
вводя «Ленина, 1», получал уверенно неверную цену по екатеринбургской улице
Ленина (обе улицы называются одинаково).
Проблема (issue #2576 / эпик расширения на область): `_nominatim_lookup`,
`_nominatim_suggest` при отсутствии маркера города/области в самом адресе
всегда молча подставляли "Екатеринбург" житель Нижнего Тагила, вводя
«Ленина, 1», получал уверенно неверную цену по екатеринбургской улице Ленина
(обе улицы называются одинаково).
#2593: Yandex Geocoder (`_yandex_lookup`/`_yandex_suggest`) удалён из geocoder —
их тесты здесь удалены вместе с ним; покрытие тех же сценариев (bias/prefix
без молчаливого "Екатеринбург") осталось на `_nominatim_lookup`.
Покрывают:
- `_resolve_city_for_geocode` приоритет: маркер в адресе > `city_hint` > None.
- `_yandex_lookup` без города НЕ получает "Екатеринбург, "-префикс и bias
смещён на всю область (не форсит ЕКБ-центр); с `city_hint` префикс из hint'а;
с явным "Екатеринбург" в адресе поведение НЕ изменилось (как раньше).
- `_yandex_suggest` (Tier 1 bounded) то же самое, плюс bias/rspn.
- `_nominatim_suggest` то же самое (суффикс города, не префикс).
- `_nominatim_suggest` без города НЕ форсит city-суффикс молчаливо (dual-query);
с `city_hint` суффикс из hint'а; с явным "Екатеринбург" в адресе — поведение
НЕ изменилось (как раньше).
- `geocode()` `city_ambiguous=True` когда город не указан ни в адресе, ни в
`city_hint`; `False` когда указан явно (текстом или через `city_hint`).
- Cache-ключ (`_cache_key`) разные `city_hint` для одного текста адреса НЕ
@ -27,19 +29,14 @@ from unittest.mock import AsyncMock, MagicMock, patch
os.environ.setdefault("DATABASE_URL", "postgresql://test:test@localhost/test_db")
import httpx
import pytest
from app.services.geocoder import (
EKB_BBOX,
OBLAST66_VIEWBOX,
GeocodeResult,
GeocodeSuggestion,
_cache_key,
_nominatim_suggest,
_resolve_city_for_geocode,
_yandex_lookup,
_yandex_suggest,
geocode,
suggest,
)
@ -93,139 +90,6 @@ def test_cache_key_hint_normalized() -> None:
assert _cache_key("ленина, 1", "Нижний Тагил") == _cache_key("ленина, 1", "нижний тагил ")
# ── _yandex_lookup — query string + bias ─────────────────────────────────────
_REAL_ASYNC_CLIENT = httpx.AsyncClient
def _yandex_client_factory(transport: httpx.MockTransport):
def factory(*_: object, **__: object) -> httpx.AsyncClient:
return _REAL_ASYNC_CLIENT(transport=transport)
return factory
def _empty_yandex_payload() -> dict:
return {"response": {"GeoObjectCollection": {"featureMember": []}}}
async def test_yandex_lookup_no_city_no_prefix_and_oblast_bias() -> None:
"""#2576: без города в адресе/hint — Yandex-запрос БЕЗ "Екатеринбург, "-префикса,
bias смещён на всю область (не форсит ЕКБ-центр по умолчанию)."""
captured: dict[str, str | None] = {}
def handler(request: httpx.Request) -> httpx.Response:
captured["geocode"] = request.url.params.get("geocode")
captured["ll"] = request.url.params.get("ll")
captured["spn"] = request.url.params.get("spn")
return httpx.Response(200, json=_empty_yandex_payload())
transport = httpx.MockTransport(handler)
with patch("app.services.geocoder.httpx.AsyncClient", _yandex_client_factory(transport)):
result = await _yandex_lookup("Ленина, 1", "fake-key")
assert result is None # пустой featureMember
assert captured["geocode"] == "Ленина, 1"
assert "Екатеринбург" not in (captured["geocode"] or "")
assert captured["ll"] == OBLAST66_VIEWBOX["ll"]
assert captured["spn"] == OBLAST66_VIEWBOX["spn"]
async def test_yandex_lookup_city_hint_prefix() -> None:
"""city_hint="Нижний Тагил" → запрос получает префикс из hint'а, не "Екатеринбург"."""
captured: dict[str, str | None] = {}
def handler(request: httpx.Request) -> httpx.Response:
captured["geocode"] = request.url.params.get("geocode")
captured["ll"] = request.url.params.get("ll")
return httpx.Response(200, json=_empty_yandex_payload())
transport = httpx.MockTransport(handler)
with patch("app.services.geocoder.httpx.AsyncClient", _yandex_client_factory(transport)):
await _yandex_lookup("Ленина, 1", "fake-key", city_hint="Нижний Тагил")
assert captured["geocode"] == "Нижний Тагил, Ленина, 1"
# Тагил — не ЕКБ-контекст → bias не форсит ЕКБ-центр.
assert captured["ll"] == OBLAST66_VIEWBOX["ll"]
async def test_yandex_lookup_explicit_ekaterinburg_unchanged() -> None:
"""Явное "Екатеринбург" в адресе → поведение НЕ изменилось (как до фикса)."""
captured: dict[str, str | None] = {}
def handler(request: httpx.Request) -> httpx.Response:
captured["geocode"] = request.url.params.get("geocode")
captured["ll"] = request.url.params.get("ll")
return httpx.Response(200, json=_empty_yandex_payload())
transport = httpx.MockTransport(handler)
with patch("app.services.geocoder.httpx.AsyncClient", _yandex_client_factory(transport)):
await _yandex_lookup("Екатеринбург, Малышева 30", "fake-key")
assert captured["geocode"] == "Екатеринбург, Малышева 30"
assert captured["ll"] == EKB_BBOX["ll"]
# ── _yandex_suggest (Tier 1 bounded) ─────────────────────────────────────────
async def test_yandex_suggest_no_city_uses_oblast_bounded() -> None:
"""#2576: автокомплит без города — bounded по ВСЕЙ области, без city-префикса
(раньше Tier 1 всегда форсил bounded-ЕКБ с "Екатеринбург, ")."""
calls: list[tuple[str, bool, dict[str, str] | None]] = []
async def fake_request(client, api_key, query, limit, bounded, bias=None):
calls.append((query, bounded, bias))
return []
with patch(
"app.services.geocoder._yandex_geocode_request", new=AsyncMock(side_effect=fake_request)
):
result = await _yandex_suggest("Ленина, 1", "fake-key")
assert result == []
assert calls, "expected at least one Yandex request"
first_query, first_bounded, first_bias = calls[0]
assert first_query == "Ленина, 1"
assert "Екатеринбург" not in first_query
assert first_bounded is True
assert first_bias == OBLAST66_VIEWBOX
async def test_yandex_suggest_city_hint_prefix_bounded() -> None:
calls: list[tuple[str, bool, dict[str, str] | None]] = []
async def fake_request(client, api_key, query, limit, bounded, bias=None):
calls.append((query, bounded, bias))
return []
with patch(
"app.services.geocoder._yandex_geocode_request", new=AsyncMock(side_effect=fake_request)
):
await _yandex_suggest("Ленина, 1", "fake-key", city_hint="Нижний Тагил")
first_query, _, first_bias = calls[0]
assert first_query == "Нижний Тагил, Ленина, 1"
assert first_bias == OBLAST66_VIEWBOX
async def test_yandex_suggest_explicit_ekb_unchanged() -> None:
calls: list[tuple[str, bool, dict[str, str] | None]] = []
async def fake_request(client, api_key, query, limit, bounded, bias=None):
calls.append((query, bounded, bias))
return []
with patch(
"app.services.geocoder._yandex_geocode_request", new=AsyncMock(side_effect=fake_request)
):
await _yandex_suggest("Екатеринбург, Малышева 30", "fake-key")
first_query, _, first_bias = calls[0]
assert first_query == "Екатеринбург, Малышева 30"
assert first_bias == EKB_BBOX
# ── _nominatim_suggest ───────────────────────────────────────────────────────
@ -363,14 +227,16 @@ async def test_nominatim_suggest_dedupe_across_dual_query() -> None:
# ── geocode() — city_ambiguous flag ──────────────────────────────────────────
def _geocode_patches(yandex_result: GeocodeResult | None):
def _geocode_patches(nominatim_result: GeocodeResult | None):
return (
patch("app.services.geocoder._cache_get", return_value=None),
patch("app.services.geocoder._cache_put"),
patch("app.services.geocoder._geoportal_house_match", return_value=None),
patch("app.services.geocoder._cadastral_house_match", return_value=None),
patch("app.services.geocoder._cadastral_forward_sync", return_value=[]),
patch("app.services.geocoder._yandex_lookup", new=AsyncMock(return_value=yandex_result)),
patch(
"app.services.geocoder._nominatim_lookup", new=AsyncMock(return_value=nominatim_result)
),
)
@ -393,29 +259,25 @@ async def test_geocode_city_hint_non_ekb_skips_local_ekb_tiers() -> None:
kind="house",
)
tagil_result = GeocodeResult(
lat=57.905, lon=59.950, full_address="Ленина, 1, Нижний Тагил", provider="yandex"
lat=57.905, lon=59.950, full_address="Ленина, 1, Нижний Тагил", provider="nominatim"
)
with patch("app.services.geocoder.settings") as mock_settings:
mock_settings.yandex_geocoder_api_key = "fake"
with contextlib.ExitStack() as stack:
stack.enter_context(patch("app.services.geocoder._cache_get", return_value=None))
stack.enter_context(patch("app.services.geocoder._cache_put"))
geoportal_mock = stack.enter_context(
patch("app.services.geocoder._geoportal_house_match", return_value=ekb_hit)
with contextlib.ExitStack() as stack:
stack.enter_context(patch("app.services.geocoder._cache_get", return_value=None))
stack.enter_context(patch("app.services.geocoder._cache_put"))
geoportal_mock = stack.enter_context(
patch("app.services.geocoder._geoportal_house_match", return_value=ekb_hit)
)
cadastral_mock = stack.enter_context(
patch("app.services.geocoder._cadastral_house_match", return_value=ekb_hit)
)
stack.enter_context(patch("app.services.geocoder._cadastral_forward_sync", return_value=[]))
stack.enter_context(
patch(
"app.services.geocoder._nominatim_lookup",
new=AsyncMock(return_value=tagil_result),
)
cadastral_mock = stack.enter_context(
patch("app.services.geocoder._cadastral_house_match", return_value=ekb_hit)
)
stack.enter_context(
patch("app.services.geocoder._cadastral_forward_sync", return_value=[])
)
stack.enter_context(
patch(
"app.services.geocoder._yandex_lookup",
new=AsyncMock(return_value=tagil_result),
)
)
result = await geocode("Ленина, 1", db, city_hint="Нижний Тагил")
)
result = await geocode("Ленина, 1", db, city_hint="Нижний Тагил")
geoportal_mock.assert_not_called()
cadastral_mock.assert_not_called()
@ -459,7 +321,6 @@ async def test_suggest_city_hint_non_ekb_skips_cadastral_tier1() -> None:
forward_mock = stack.enter_context(patch("app.services.geocoder._cadastral_forward_sync"))
mock_settings = stack.enter_context(patch("app.services.geocoder.settings"))
mock_settings.dadata_api_token = None
mock_settings.yandex_geocoder_api_key = None
stack.enter_context(
patch("app.services.geocoder._nominatim_suggest", new=AsyncMock(return_value=[]))
)
@ -472,13 +333,13 @@ async def test_suggest_city_hint_non_ekb_skips_cadastral_tier1() -> None:
async def test_geocode_city_ambiguous_true_when_no_city_known() -> None:
"""Ни адрес, ни city_hint не называют город → city_ambiguous=True."""
db = MagicMock()
yandex_result = GeocodeResult(lat=56.838, lon=60.605, full_address="что-то", provider="yandex")
with patch("app.services.geocoder.settings") as mock_settings:
mock_settings.yandex_geocoder_api_key = "fake"
with contextlib.ExitStack() as stack:
for cm in _geocode_patches(yandex_result):
stack.enter_context(cm)
result = await geocode("Малышева, 30", db)
nominatim_result = GeocodeResult(
lat=56.838, lon=60.605, full_address="что-то", provider="nominatim"
)
with contextlib.ExitStack() as stack:
for cm in _geocode_patches(nominatim_result):
stack.enter_context(cm)
result = await geocode("Малышева, 30", db)
assert result is not None
assert result.city_ambiguous is True
@ -487,15 +348,13 @@ async def test_geocode_city_ambiguous_true_when_no_city_known() -> None:
async def test_geocode_city_ambiguous_false_when_marker_present() -> None:
"""Явный "Екатеринбург" в адресе → город указан пользователем → city_ambiguous=False."""
db = MagicMock()
yandex_result = GeocodeResult(
lat=56.838, lon=60.605, full_address="Екатеринбург, Малышева, 30", provider="yandex"
nominatim_result = GeocodeResult(
lat=56.838, lon=60.605, full_address="Екатеринбург, Малышева, 30", provider="nominatim"
)
with patch("app.services.geocoder.settings") as mock_settings:
mock_settings.yandex_geocoder_api_key = "fake"
with contextlib.ExitStack() as stack:
for cm in _geocode_patches(yandex_result):
stack.enter_context(cm)
result = await geocode("Екатеринбург, Малышева, 30", db)
with contextlib.ExitStack() as stack:
for cm in _geocode_patches(nominatim_result):
stack.enter_context(cm)
result = await geocode("Екатеринбург, Малышева, 30", db)
assert result is not None
assert result.city_ambiguous is False
@ -504,15 +363,13 @@ async def test_geocode_city_ambiguous_false_when_marker_present() -> None:
async def test_geocode_city_ambiguous_false_when_city_hint_given() -> None:
"""city_hint передан вызывающим кодом → город указан → city_ambiguous=False."""
db = MagicMock()
yandex_result = GeocodeResult(
lat=57.905, lon=59.950, full_address="Нижний Тагил, Ленина, 1", provider="yandex"
nominatim_result = GeocodeResult(
lat=57.905, lon=59.950, full_address="Нижний Тагил, Ленина, 1", provider="nominatim"
)
with patch("app.services.geocoder.settings") as mock_settings:
mock_settings.yandex_geocoder_api_key = "fake"
with contextlib.ExitStack() as stack:
for cm in _geocode_patches(yandex_result):
stack.enter_context(cm)
result = await geocode("Ленина, 1", db, city_hint="Нижний Тагил")
with contextlib.ExitStack() as stack:
for cm in _geocode_patches(nominatim_result):
stack.enter_context(cm)
result = await geocode("Ленина, 1", db, city_hint="Нижний Тагил")
assert result is not None
assert result.city_ambiguous is False
@ -535,45 +392,37 @@ async def test_geocode_cache_does_not_mix_cities() -> None:
def fake_cache_put(db, addr_norm, result):
store[addr_norm] = result
async def fake_yandex_lookup(address, api_key, city_hint=None):
async def fake_nominatim_lookup(address, city_hint=None):
if city_hint == "Нижний Тагил":
return GeocodeResult(
lat=57.905, lon=59.950, full_address="Нижний Тагил, Ленина, 1", provider="yandex"
lat=57.905, lon=59.950, full_address="Нижний Тагил, Ленина, 1", provider="nominatim"
)
return GeocodeResult(
lat=56.838, lon=60.605, full_address="Екатеринбург, Ленина, 1", provider="yandex"
lat=56.838, lon=60.605, full_address="Екатеринбург, Ленина, 1", provider="nominatim"
)
db = MagicMock()
with patch("app.services.geocoder.settings") as mock_settings:
mock_settings.yandex_geocoder_api_key = "fake"
with contextlib.ExitStack() as stack:
stack.enter_context(
patch("app.services.geocoder._cache_get", side_effect=fake_cache_get)
)
stack.enter_context(
patch("app.services.geocoder._cache_put", side_effect=fake_cache_put)
)
stack.enter_context(
patch("app.services.geocoder._geoportal_house_match", return_value=None)
)
stack.enter_context(
patch("app.services.geocoder._cadastral_house_match", return_value=None)
)
stack.enter_context(
patch("app.services.geocoder._cadastral_forward_sync", return_value=[])
)
stack.enter_context(
patch(
"app.services.geocoder._yandex_lookup",
new=AsyncMock(side_effect=fake_yandex_lookup),
)
with contextlib.ExitStack() as stack:
stack.enter_context(patch("app.services.geocoder._cache_get", side_effect=fake_cache_get))
stack.enter_context(patch("app.services.geocoder._cache_put", side_effect=fake_cache_put))
stack.enter_context(
patch("app.services.geocoder._geoportal_house_match", return_value=None)
)
stack.enter_context(
patch("app.services.geocoder._cadastral_house_match", return_value=None)
)
stack.enter_context(patch("app.services.geocoder._cadastral_forward_sync", return_value=[]))
stack.enter_context(
patch(
"app.services.geocoder._nominatim_lookup",
new=AsyncMock(side_effect=fake_nominatim_lookup),
)
)
r_ekb = await geocode("Ленина, 1", db, city_hint="Екатеринбург")
r_tagil = await geocode("Ленина, 1", db, city_hint="Нижний Тагил")
# Повторный запрос ЕКБ — должен снова попасть в СВОЙ кэш (не Тагила).
r_ekb_again = await geocode("Ленина, 1", db, city_hint="Екатеринбург")
r_ekb = await geocode("Ленина, 1", db, city_hint="Екатеринбург")
r_tagil = await geocode("Ленина, 1", db, city_hint="Нижний Тагил")
# Повторный запрос ЕКБ — должен снова попасть в СВОЙ кэш (не Тагила).
r_ekb_again = await geocode("Ленина, 1", db, city_hint="Екатеринбург")
assert r_ekb is not None and r_tagil is not None and r_ekb_again is not None
assert r_ekb.lat == pytest.approx(56.838)