From 3d075632a9aa0e66c0a644540b73e2b0b8e056a6 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Fri, 31 Jul 2026 21:22:09 +0300 Subject: [PATCH] =?UTF-8?q?chore(tradein/geocoder):=20=D1=83=D0=B4=D0=B0?= =?UTF-8?q?=D0=BB=D0=B8=D1=82=D1=8C=20=D0=AF=D0=BD=D0=B4=D0=B5=D0=BA=D1=81?= =?UTF-8?q?-=D0=B3=D0=B5=D0=BE=D0=BA=D0=BE=D0=B4=D0=B5=D1=80=20(#2593)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Убирает ядро Yandex Geocoder (forward/reverse/suggest lookups + region-check + bias-хелперы + EKB_BBOX dict) из app/services/geocoder.py — Yandex demo-key исчерпан, Nominatim/DaData/локальные ЕКБ-тиры (geoportal/cadastral) остаются единственными живыми провайдерами. Цепочка тиров после удаления: кэш → геопортал ЕКБ → кадастр (house-match) → кадастр (raw) → Nominatim; в подсказках дополнительно DaData. НЕ затронуто (намеренно): Yandex.Недвижимость как источник объявлений (source='yandex', yandex_city_sweep*, providers/yandex/serp.py:geocoderAddress), Avito geocoder (providers/avito/imv.py:_geocode), EKB_BBOX_TIGHT/WIDE, _nominatim_region_ok, scripts/*_yandex_reverse.py и их тесты, tests/fixtures/ yandex_geocode_sample.json (всё ещё используется test_audit_address_mismatch.py). _SNAP_PRECISIONS оставлен с "exact" (недостижимо без Yandex-tier, но дёшево хранить — parity с frontend MapPicker.tsx SNAP_PRECISIONS и не ломает test_snap_precision_useful_exact_and_number). --- tradein-mvp/backend/app/api/v1/geocode.py | 4 +- tradein-mvp/backend/app/core/config.py | 5 - tradein-mvp/backend/app/core/fdw.py | 23 +- tradein-mvp/backend/app/services/estimator.py | 5 +- tradein-mvp/backend/app/services/geocoder.py | 491 ++---------------- .../backend/app/tasks/geocode_missing.py | 2 +- .../tests/services/test_cadastral_reverse.py | 143 +---- .../tests/test_ekb_geoportal_ingest.py | 6 - .../backend/tests/test_geocode_reverse_api.py | 109 +--- .../backend/tests/test_geocoder_bbox.py | 76 +-- .../tests/test_geocoder_cadastral_matcher.py | 18 - .../backend/tests/test_geocoder_city_hint.py | 299 +++-------- 12 files changed, 168 insertions(+), 1013 deletions(-) diff --git a/tradein-mvp/backend/app/api/v1/geocode.py b/tradein-mvp/backend/app/api/v1/geocode.py index 7b981887..e9c25631 100644 --- a/tradein-mvp/backend/app/api/v1/geocode.py +++ b/tradein-mvp/backend/app/api/v1/geocode.py @@ -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) diff --git a/tradein-mvp/backend/app/core/config.py b/tradein-mvp/backend/app/core/config.py index a8da5bb2..b4a3f507 100644 --- a/tradein-mvp/backend/app/core/config.py +++ b/tradein-mvp/backend/app/core/config.py @@ -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" diff --git a/tradein-mvp/backend/app/core/fdw.py b/tradein-mvp/backend/app/core/fdw.py index ec3cbf78..a5892289 100644 --- a/tradein-mvp/backend/app/core/fdw.py +++ b/tradein-mvp/backend/app/core/fdw.py @@ -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: diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index a37ee550..96b802bd 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -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, diff --git a/tradein-mvp/backend/app/services/geocoder.py b/tradein-mvp/backend/app/services/geocoder.py index 0a503cca..ff832001 100644 --- a/tradein-mvp/backend/app/services/geocoder.py +++ b/tradein-mvp/backend/app/services/geocoder.py @@ -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: diff --git a/tradein-mvp/backend/app/tasks/geocode_missing.py b/tradein-mvp/backend/app/tasks/geocode_missing.py index 2dcfd9f2..38776907 100644 --- a/tradein-mvp/backend/app/tasks/geocode_missing.py +++ b/tradein-mvp/backend/app/tasks/geocode_missing.py @@ -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). diff --git a/tradein-mvp/backend/tests/services/test_cadastral_reverse.py b/tradein-mvp/backend/tests/services/test_cadastral_reverse.py index 2204774a..fdd2d88b 100644 --- a/tradein-mvp/backend/tests/services/test_cadastral_reverse.py +++ b/tradein-mvp/backend/tests/services/test_cadastral_reverse.py @@ -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 ───────────────── diff --git a/tradein-mvp/backend/tests/test_ekb_geoportal_ingest.py b/tradein-mvp/backend/tests/test_ekb_geoportal_ingest.py index 638e3bfa..6ccd81f0 100644 --- a/tradein-mvp/backend/tests/test_ekb_geoportal_ingest.py +++ b/tradein-mvp/backend/tests/test_ekb_geoportal_ingest.py @@ -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 diff --git a/tradein-mvp/backend/tests/test_geocode_reverse_api.py b/tradein-mvp/backend/tests/test_geocode_reverse_api.py index a50bb838..7b5adebe 100644 --- a/tradein-mvp/backend/tests/test_geocode_reverse_api.py +++ b/tradein-mvp/backend/tests/test_geocode_reverse_api.py @@ -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 diff --git a/tradein-mvp/backend/tests/test_geocoder_bbox.py b/tradein-mvp/backend/tests/test_geocoder_bbox.py index eacc7d3a..93457634 100644 --- a/tradein-mvp/backend/tests/test_geocoder_bbox.py +++ b/tradein-mvp/backend/tests/test_geocoder_bbox.py @@ -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 diff --git a/tradein-mvp/backend/tests/test_geocoder_cadastral_matcher.py b/tradein-mvp/backend/tests/test_geocoder_cadastral_matcher.py index 3e179c8c..a2e78b57 100644 --- a/tradein-mvp/backend/tests/test_geocoder_cadastral_matcher.py +++ b/tradein-mvp/backend/tests/test_geocoder_cadastral_matcher.py @@ -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 diff --git a/tradein-mvp/backend/tests/test_geocoder_city_hint.py b/tradein-mvp/backend/tests/test_geocoder_city_hint.py index 5a5666c2..0565aa0d 100644 --- a/tradein-mvp/backend/tests/test_geocoder_city_hint.py +++ b/tradein-mvp/backend/tests/test_geocoder_city_hint.py @@ -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) -- 2.45.3