fix(tradein/geocoder): не подставлять Екатеринбург молча — явный city_hint (#2576)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 11s
CI / changes (pull_request) Successful in 10s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 2m17s

Раньше _yandex_lookup/_yandex_suggest/_nominatim_suggest молча подставляли
"Екатеринбург, " в запрос, если в адресе не было маркера города/области.
Житель Нижнего Тагила, вводя «Ленина, 1», получал уверенно неверную цену по
екатеринбургской улице Ленина (обе улицы называются одинаково) — фронт город
вообще не передаёт.

- geocode()/suggest() принимают опциональный city_hint: str | None; без него
  внешние тиры больше НЕ подставляют город, а bias (ll/spn) смещается на всю
  область (OBLAST66_VIEWBOX) вместо ЕКБ-центра. Явный маркер города в адресе
  или city_hint сохраняют прежнее поведение (ЕКБ-путь не деградирует).
- GeocodeResult.city_ambiguous — честный флаг «город определил провайдер, а
  не пользователь» (не эвристика на корректность), проброшен в
  AggregatedEstimate.target_city_ambiguous (ephemeral, не персистится).
- Cache-ключ geocode_cache учитывает city_hint (address|city=...) — без hint'а
  формат не меняется (backward-compat), с hint'ом разные города для одного
  текста адреса больше не делят одну запись.
- API: /api/v1/geocode/lookup, /suggest и POST /trade-in/estimate получили
  опциональный city_hint — контракт не ломается (default None).

23 новых теста в test_geocoder_city_hint.py; проверено что они падают
(ImportError на _cache_key) на коде до фикса через git stash.
This commit is contained in:
bot-backend 2026-07-31 16:42:29 +03:00
parent 910ef2503c
commit 4b1a282234
5 changed files with 637 additions and 42 deletions

View file

@ -21,14 +21,27 @@ router = APIRouter()
async def lookup( async def lookup(
address: Annotated[str, Query(min_length=3, max_length=500)], address: Annotated[str, Query(min_length=3, max_length=500)],
db: Annotated[Session, Depends(get_db)], db: Annotated[Session, Depends(get_db)],
city_hint: Annotated[
str | None,
Query(
max_length=100,
description=(
"Город, если известен вызывающему (например выбран пользователем "
"на предыдущем шаге UI). #2576: без него геокодер БОЛЬШЕ НЕ "
"подставляет 'Екатеринбург' молча — ответ может помечаться "
"city_ambiguous=true."
),
),
] = None,
) -> GeocodeResult: ) -> GeocodeResult:
"""Геокодинг адреса → lat/lon. """Геокодинг адреса → lat/lon.
Примеры: Примеры:
/api/v1/geocode/lookup?address=ул.+Малышева+30+Екатеринбург /api/v1/geocode/lookup?address=ул.+Малышева+30+Екатеринбург
/api/v1/geocode/lookup?address=Куйбышева+50+Екатеринбург /api/v1/geocode/lookup?address=Куйбышева+50+Екатеринбург
/api/v1/geocode/lookup?address=Ленина+1&city_hint=Нижний+Тагил
""" """
result = await geocode(address, db) result = await geocode(address, db, city_hint=city_hint)
if result is None: if result is None:
raise HTTPException(status_code=404, detail=f"Address not found: {address}") raise HTTPException(status_code=404, detail=f"Address not found: {address}")
return result return result
@ -55,6 +68,16 @@ async def suggest_addresses(
q: Annotated[str, Query(min_length=2, max_length=200, description="Запрос для автокомплита")], q: Annotated[str, Query(min_length=2, max_length=200, description="Запрос для автокомплита")],
limit: Annotated[int, Query(ge=1, le=15)] = 8, limit: Annotated[int, Query(ge=1, le=15)] = 8,
db: Annotated[Session, Depends(get_db)] = None, # type: ignore[assignment] db: Annotated[Session, Depends(get_db)] = None, # type: ignore[assignment]
city_hint: Annotated[
str | None,
Query(
max_length=100,
description=(
"Город, если известен вызывающему (#2576) — без него подсказки "
"БОЛЬШЕ НЕ ограничиваются молчаливо Екатеринбургом."
),
),
] = None,
) -> SuggestResponse: ) -> SuggestResponse:
"""Автокомплит адресов в Свердловской области (region 66; ЕКБ — основной трафик, """Автокомплит адресов в Свердловской области (region 66; ЕКБ — основной трафик,
остаётся быстрым fast-path). остаётся быстрым fast-path).
@ -66,8 +89,9 @@ async def suggest_addresses(
Пример: Пример:
/api/v1/geocode/suggest?q=Малышева /api/v1/geocode/suggest?q=Малышева
/api/v1/geocode/suggest?q=Цвиллинга # → пусто, такой улицы в ЕКБ нет /api/v1/geocode/suggest?q=Цвиллинга # → пусто, такой улицы в ЕКБ нет
/api/v1/geocode/suggest?q=Ленина+1&city_hint=Нижний+Тагил
""" """
items = await suggest(q, db=db, limit=limit) items = await suggest(q, db=db, limit=limit, city_hint=city_hint)
return SuggestResponse( return SuggestResponse(
items=[ items=[
SuggestItem( SuggestItem(

View file

@ -27,6 +27,12 @@ class TradeInEstimateInput(BaseModel):
# geocode() (который падает на DaData-формах при мёртвом Yandex-ключе). # geocode() (который падает на DaData-формах при мёртвом Yandex-ключе).
lat: float | None = Field(default=None, ge=-90, le=90) lat: float | None = Field(default=None, ge=-90, le=90)
lon: float | None = Field(default=None, ge=-180, le=180) lon: float | None = Field(default=None, ge=-180, le=180)
# #2576: город, если известен фронту (например выбран отдельным полем UI).
# Опционально — без него geocode() внутри estimate_quality() БОЛЬШЕ НЕ
# подставляет "Екатеринбург" молча (см. app.services.geocoder), что раньше
# давало уверенно неверную цену для жителей других городов области (те же
# улица+дом существуют и в ЕКБ, и, например, в Нижнем Тагиле).
city_hint: str | None = Field(default=None, max_length=100)
# ФИАС/ГАР OBJECTGUID целевого дома, если фронт разрешил его через suggest # ФИАС/ГАР OBJECTGUID целевого дома, если фронт разрешил его через suggest
# (SuggestItem.fias_id у house-level кандидата). Прокидывается в матчер # (SuggestItem.fias_id у house-level кандидата). Прокидывается в матчер
# (Tier 0.5 fias_exact) ПЕРВЫМ, до fias из DaData /clean. Additive/optional — # (Tier 0.5 fias_exact) ПЕРВЫМ, до fias из DaData /clean. Additive/optional —
@ -185,6 +191,12 @@ class AggregatedEstimate(BaseModel):
target_address: str | None = None # geocoded full address target_address: str | None = None # geocoded full address
target_lat: float | None = None target_lat: float | None = None
target_lon: float | None = None target_lon: float | None = None
# #2576: True если ни адрес, ни `TradeInEstimateInput.city_hint` не называли
# город явно — итоговый город (и, соответственно, набор аналогов/цена)
# определил геокодер-провайдер, а не пользователь. Честный сигнал для
# UI (снизить доверие / переспросить город), НЕ персистится в БД
# (ephemeral, только для текущего POST /estimate ответа).
target_city_ambiguous: bool = False
sources_used: list[str] = Field(default_factory=list) # ['avito', 'cian', 'rosreestr'] sources_used: list[str] = Field(default_factory=list) # ['avito', 'cian', 'rosreestr']
data_freshness_minutes: int | None = None # сколько минут назад был самый свежий парсинг data_freshness_minutes: int | None = None # сколько минут назад был самый свежий парсинг
# абсолютный timestamp самого свежего парсинга аналогов # абсолютный timestamp самого свежего парсинга аналогов

View file

@ -3180,8 +3180,12 @@ async def estimate_quality(
payload.lon, payload.lon,
) )
if geo is None and payload.address: if geo is None and payload.address:
# #2576: city_hint прокидывается из payload — БЕЗ него geocode() больше не
# подставляет "Екатеринбург" молча (см. app.services.geocoder). Опционально:
# фронт пока (до отдельного изменения UI) его не шлёт, geo.city_ambiguous
# честно сигнализирует об этом ниже.
geo = await _with_budget( geo = await _with_budget(
geocode(payload.address, db), geocode(payload.address, db, city_hint=payload.city_hint),
settings.estimate_geocode_budget_s, settings.estimate_geocode_budget_s,
label="geocode", label="geocode",
) )
@ -3880,6 +3884,7 @@ async def estimate_quality(
target_address=geo.full_address, target_address=geo.full_address,
target_lat=geo.lat, target_lat=geo.lat,
target_lon=geo.lon, target_lon=geo.lon,
target_city_ambiguous=geo.city_ambiguous,
sources_used=sources_used, sources_used=sources_used,
data_freshness_minutes=freshness_min, data_freshness_minutes=freshness_min,
last_scraped_at=last_scraped_at, last_scraped_at=last_scraped_at,

View file

@ -16,7 +16,7 @@ from __future__ import annotations
import asyncio import asyncio
import logging import logging
import re import re
from dataclasses import dataclass from dataclasses import dataclass, replace
from typing import Literal from typing import Literal
import httpx import httpx
@ -38,6 +38,12 @@ class GeocodeResult:
full_address: str full_address: str
provider: Literal["nominatim", "yandex", "cache"] provider: Literal["nominatim", "yandex", "cache"]
confidence: Literal["exact", "approximate", "locality"] = "approximate" confidence: Literal["exact", "approximate", "locality"] = "approximate"
# #2576: True если город НЕ был указан пользователем (ни в тексте адреса, ни
# через `city_hint`) — т.е. итоговый город результата определил провайдер
# (или локальный ЕКБ-тир), а не вызывающий код. Не эвристика на «правильность»
# результата — честный сигнал «доверяй, но проверяй», чтобы вызывающий код мог
# понизить confidence / переспросить город у пользователя. См. `_resolve_city_for_geocode`.
city_ambiguous: bool = False
# ── EKB bounding boxes ─────────────────────────────────────────────────────── # ── EKB bounding boxes ───────────────────────────────────────────────────────
@ -191,6 +197,53 @@ def _has_oblast_marker(text_lower: str) -> bool:
return False return False
def _resolve_city_for_geocode(address: str, city_hint: str | None) -> tuple[str | None, bool]:
"""Определяет, какой город подставлять в запрос внешнему провайдеру (Yandex/
Nominatim), когда сам текст адреса города не называет.
Приоритет:
1. Адрес уже содержит маркер города/области региона 66 (`_has_oblast_marker`)
город уже указан пользователем в тексте адреса, ничего подставлять не
нужно. Возвращает (None, True).
2. `city_hint` передан вызывающим кодом (например, фронт знает выбранный
город из предыдущего шага UI) подставляем его. Возвращает (city, True).
3. Ни то, ни другое раньше (#2576) здесь молча подставлялся "Екатеринбург"
для жителей других городов области это давало уверенно неверную цену
(«Ленина, 1» в Нижнем Тагиле снапалось на екатеринбургскую улицу Ленина,
обе улицы называются одинаково). Теперь НЕ подставляем никакой город
провайдер ищет по OBLAST66 viewbox/bbox (см. `_yandex_bias`,
`OBLAST66_VIEWBOX`), без привязки к конкретному городу. Возвращает
(None, False) второй элемент False сигнализирует, что город
пользователь НЕ указывал (источник `GeocodeResult.city_ambiguous`).
Returns:
(city_or_none, city_specified_by_user).
"""
if _has_oblast_marker(address.lower()):
return None, True
hint = (city_hint or "").strip()
if hint:
return hint, True
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 # Города региона 66 КРОМЕ Екатеринбурга — используется чтобы отсечь EKB-only
# локальные тиры (geoportal/cadastral, см. `geocode()`) от адреса другого # локальные тиры (geoportal/cadastral, см. `geocode()`) от адреса другого
# города области. re.escape на элементах SVERDLOVSK_OBLAST_CITIES-{ekb}. # города области. re.escape на элементах SVERDLOVSK_OBLAST_CITIES-{ekb}.
@ -240,6 +293,29 @@ def normalize_address(address: str) -> str:
return " ".join(address.lower().strip().split()) return " ".join(address.lower().strip().split())
def _cache_key(address_norm: str, city_hint: str | None) -> str:
"""Ключ `geocode_cache.address_normalized` — адрес, дополненный городом,
если он известен вызывающему коду.
#2576: раньше ключ был просто нормализованный адрес — одинаковый для
«Ленина, 1» независимо от того, кто спрашивает (ЕКБ или Нижний Тагил).
Т.к. геокодер раньше молча предполагал ЕКБ, оба города писали/читали ОДНУ
и ту же строку кэша взаимная порча (первый запрос «застолбил» город для
второго). С `city_hint` разные города для одного текста адреса больше не
делят один ключ.
БЕЗ `city_hint` формат ключа не меняется (backward-compatible с уже
накопленным кэшем) коллизия между городами для запросов без hint'а
остаётся возможной (структурно неизбежно, пока вызывающий код не начнёт
передавать city_hint повсеместно), но `city_ambiguous` на результате
честно сигнализирует об этом вызывающему.
"""
city_norm = " ".join((city_hint or "").lower().strip().split())
if not city_norm:
return address_norm
return f"{address_norm}|city={city_norm}"
# Согласные, которые часто пишут с одной буквой вместо двух (RU typos). # Согласные, которые часто пишут с одной буквой вместо двух (RU typos).
_DOUBLE_CONSONANTS = "лнмссккттпп" _DOUBLE_CONSONANTS = "лнмссккттпп"
@ -451,17 +527,24 @@ def _yandex_region_ok(geo_object: dict) -> bool | None:
# ── Provider: Yandex Geocoder (требует key, лучшее покрытие РФ) ───────────── # ── Provider: Yandex Geocoder (требует key, лучшее покрытие РФ) ─────────────
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=8)) @retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=8))
async def _yandex_lookup(address: str, api_key: str) -> GeocodeResult | None: async def _yandex_lookup(
address: str, api_key: str, city_hint: str | None = None
) -> GeocodeResult | None:
"""Yandex Geocoder — 25K req/day free для самопод, лучше РФ. """Yandex Geocoder — 25K req/day free для самопод, лучше РФ.
Docs: https://yandex.ru/dev/maps/geocoder/doc/desc/concepts/input_params.html Docs: https://yandex.ru/dev/maps/geocoder/doc/desc/concepts/input_params.html
Запрашиваем с ll+spn (центр ЕКБ) для приоритизации местных результатов, Запрашиваем с ll+spn (центр ЕКБ, если контекст ЕКБ, иначе центр всей
но БЕЗ rspn чтобы fuzzy matching работал при опечатках. области см. `_yandex_bias`) для приоритизации местных результатов, но
БЕЗ rspn чтобы fuzzy matching работал при опечатках.
""" """
# Не навязываем "Екатеринбург, " если в адресе уже есть город/область региона 66 # Город в запрос подставляем ТОЛЬКО если он известен (адрес уже называет
# (типичный кейс из suggest, либо явный запрос по другому городу области). # город/область региона 66, либо явный `city_hint`) — раньше (#2576) сюда
geocode_query = address if _has_oblast_marker(address.lower()) else f"Екатеринбург, {address}" # молча подставлялся "Екатеринбург" при отсутствии обоих, что давало
# уверенно неверную цену жителям других городов области.
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: async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get( response = await client.get(
"https://geocode-maps.yandex.ru/1.x/", "https://geocode-maps.yandex.ru/1.x/",
@ -471,8 +554,8 @@ async def _yandex_lookup(address: str, api_key: str) -> GeocodeResult | None:
"format": "json", "format": "json",
"results": 5, # берем top-5, отфильтруем по ЕКБ bbox ниже "results": 5, # берем top-5, отфильтруем по ЕКБ bbox ниже
"lang": "ru_RU", "lang": "ru_RU",
"ll": EKB_BBOX["ll"], "ll": bias["ll"],
"spn": EKB_BBOX["spn"], "spn": bias["spn"],
}, },
) )
response.raise_for_status() response.raise_for_status()
@ -641,17 +724,27 @@ async def _dadata_suggest(query: str, limit: int = 8) -> list[GeocodeSuggestion]
async def _yandex_geocode_request( async def _yandex_geocode_request(
client: httpx.AsyncClient, api_key: str, query: str, limit: int, bounded: bool client: httpx.AsyncClient,
api_key: str,
query: str,
limit: int,
bounded: bool,
bias: dict[str, str] | None = None,
) -> list[dict]: ) -> list[dict]:
"""Single Yandex Geocoder request — bounded=True → строго в ЕКБ через rspn=1.""" """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] = { params: dict[str, str] = {
"apikey": api_key, "apikey": api_key,
"geocode": query, "geocode": query,
"format": "json", "format": "json",
"results": str(limit), "results": str(limit),
"lang": "ru_RU", "lang": "ru_RU",
"ll": EKB_BBOX["ll"], "ll": b["ll"],
"spn": EKB_BBOX["spn"], "spn": b["spn"],
} }
if bounded: if bounded:
params["rspn"] = "1" params["rspn"] = "1"
@ -662,40 +755,50 @@ async def _yandex_geocode_request(
@retry(stop=stop_after_attempt(2), wait=wait_exponential(multiplier=1, min=1, max=4)) @retry(stop=stop_after_attempt(2), wait=wait_exponential(multiplier=1, min=1, max=4))
async def _yandex_suggest(query: str, api_key: str, limit: int = 8) -> list[GeocodeSuggestion]: async def _yandex_suggest(
query: str, api_key: str, limit: int = 8, city_hint: str | None = None
) -> list[GeocodeSuggestion]:
"""Yandex Geocoder с авто-fallback на typo-tolerant режим. """Yandex Geocoder с авто-fallback на typo-tolerant режим.
Tier 1: bounded ЕКБ (rspn=1) быстрый путь для основного (ЕКБ) трафика. Tier 1: bounded (rspn=1) быстрый путь. Bounded на ЕКБ, если контекст
Tier 2: bounded ЕКБ на typo-variants (удвоение согласных). однозначно про ЕКБ (текст адреса/`city_hint`), иначе bounded на ВСЮ область
(`OBLAST66_VIEWBOX`) раньше (#2576) Tier 1/2 всегда форсили bounded-ЕКБ
с "Екатеринбург, "-префиксом даже когда пользователь не называл город, из-за
чего автокомплит для жителей других городов области либо не находил ничего,
либо подсовывал ЕКБ-варианты вместо нужного города.
Tier 2: bounded на typo-variants (удвоение согласных), тот же bias.
Tier 3: без rspn fuzzy по всей стране, фильтр результатов по bbox области Tier 3: без rspn fuzzy по всей стране, фильтр результатов по bbox области
(region 66) ловит легитимные Нижний Тагил/Серов/etc, которые Tier 1/2 (region 66) ловит легитимные Нижний Тагил/Серов/etc, которые Tier 1/2
(bounded строго ЕКБ) структурно вернуть не могут. (bounded) структурно вернуть не могут при неверном bias.
""" """
prefixed_query = query if _has_oblast_marker(query.lower()) else f"Екатеринбург, {query}" 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: async with httpx.AsyncClient(timeout=8.0) as client:
# Tier 1: strict bounded на оригинал (ЕКБ fast path) # Tier 1: strict bounded на оригинал
members = await _yandex_geocode_request( members = await _yandex_geocode_request(
client, client,
api_key, api_key,
prefixed_query, prefixed_query,
limit, limit,
bounded=True, bounded=True,
bias=bias,
) )
results = _parse_yandex_members(members) results = _parse_yandex_members(members)
if results: if results:
return results return results
# Tier 2: bounded на typo-варианты (тот же ЕКБ fast path) # Tier 2: bounded на typo-варианты (тот же bias)
for variant in _typo_variants(query, limit=4): for variant in _typo_variants(query, limit=4):
variant_query = ( variant_city, _ = _resolve_city_for_geocode(variant, city_hint)
variant if _has_oblast_marker(variant.lower()) else f"Екатеринбург, {variant}" variant_query = f"{variant_city}, {variant}" if variant_city else variant
)
members = await _yandex_geocode_request( members = await _yandex_geocode_request(
client, client,
api_key, api_key,
variant_query, variant_query,
limit, limit,
bounded=True, bounded=True,
bias=bias,
) )
results = _parse_yandex_members(members) results = _parse_yandex_members(members)
if results: if results:
@ -708,6 +811,7 @@ async def _yandex_suggest(query: str, api_key: str, limit: int = 8) -> list[Geoc
prefixed_query, prefixed_query,
limit, limit,
bounded=False, bounded=False,
bias=bias,
) )
results = _parse_yandex_members(members) results = _parse_yandex_members(members)
in_oblast = [r for r in results if is_within_oblast66_bbox(r.lat, r.lon)] in_oblast = [r for r in results if is_within_oblast66_bbox(r.lat, r.lon)]
@ -734,18 +838,26 @@ async def _nominatim_query_multi(client: httpx.AsyncClient, query: str, limit: i
@retry(stop=stop_after_attempt(2), wait=wait_exponential(multiplier=1, min=1, max=4)) @retry(stop=stop_after_attempt(2), wait=wait_exponential(multiplier=1, min=1, max=4))
async def _nominatim_suggest(query: str, limit: int = 8) -> list[GeocodeSuggestion]: 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 (для случаев когда Yandex недоступен).
Суффикс ", Екатеринбург" навязывается ТОЛЬКО если в запросе ещё нет города/области Суффикс города навязывается ТОЛЬКО если он известен: адрес уже называет
региона 66 иначе не режем явные запросы по другим городам области. город/область региона 66, либо передан явный `city_hint`. Раньше (#2576)
при отсутствии обоих сюда молча подставлялся суффикс ", Екатеринбург"
географию поиска это не расширяло/не сужало (`_nominatim_query_multi` и
так bounded=1 по ВСЕЙ области `OBLAST66_VIEWBOX`), но текстовый суффикс
смещал ранжирование Nominatim в пользу ЕКБ-совпадений даже для адресов
из других городов области.
""" """
headers = { headers = {
"User-Agent": f"TradeInMVP/0.1 (contact: {settings.contact_email})", "User-Agent": f"TradeInMVP/0.1 (contact: {settings.contact_email})",
"Accept": "application/json", "Accept": "application/json",
"Accept-Language": "ru,en;q=0.8", "Accept-Language": "ru,en;q=0.8",
} }
suffixed_query = query if _has_oblast_marker(query.lower()) else f"{query}, Екатеринбург" city, _ = _resolve_city_for_geocode(query, city_hint)
suffixed_query = f"{query}, {city}" if city else query
async with httpx.AsyncClient(timeout=8.0, headers=headers) as client: async with httpx.AsyncClient(timeout=8.0, headers=headers) as client:
# Tier 1: оригинальный query # Tier 1: оригинальный query
data = await _nominatim_query_multi(client, suffixed_query, limit) data = await _nominatim_query_multi(client, suffixed_query, limit)
@ -754,9 +866,8 @@ async def _nominatim_suggest(query: str, limit: int = 8) -> list[GeocodeSuggesti
if not data: if not data:
for variant in _typo_variants(query, limit=3): for variant in _typo_variants(query, limit=3):
await asyncio.sleep(1.0) # Nominatim 1 req/sec await asyncio.sleep(1.0) # Nominatim 1 req/sec
variant_query = ( variant_city, _ = _resolve_city_for_geocode(variant, city_hint)
variant if _has_oblast_marker(variant.lower()) else f"{variant}, Екатеринбург" variant_query = f"{variant}, {variant_city}" if variant_city else variant
)
data = await _nominatim_query_multi(client, variant_query, limit) data = await _nominatim_query_multi(client, variant_query, limit)
if data: if data:
logger.info("nominatim suggest typo-fixed: %s%s", query, variant) logger.info("nominatim suggest typo-fixed: %s%s", query, variant)
@ -1086,13 +1197,20 @@ def _cadastral_reverse_sync(db: Session, lat: float, lon: float, radius_m: int =
return str(row.readable_address) return str(row.readable_address)
async def suggest(query: str, db: Session | None = None, limit: int = 8) -> list[GeocodeSuggestion]: async def suggest(
query: str, db: Session | None = None, limit: int = 8, city_hint: str | None = None
) -> list[GeocodeSuggestion]:
"""Автокомплит адресов в Свердловской области (region 66; ЕКБ — основной трафик, """Автокомплит адресов в Свердловской области (region 66; ЕКБ — основной трафик,
остаётся быстрым fast-path). Cadastral FDW DaData Yandex Nominatim []. остаётся быстрым fast-path). Cadastral FDW DaData Yandex Nominatim [].
db: если передан cadastral lookup через gendesign_cad_buildings (первый tier). db: если передан cadastral lookup через gendesign_cad_buildings (первый tier).
city_hint: город, если известен вызывающему коду (#2576) — прокидывается в
Yandex/Nominatim тиры, чтобы НЕ подставлять "Екатеринбург" молча, когда
пользователь его не называл. Опционально, backward-compatible (None
прежнее поведение минус молчаливый EKB-дефолт, см. `_resolve_city_for_geocode`).
DaData /suggest (PR Q2) token-only, 10k/день, заменяет Yandex который DaData /suggest (PR Q2) token-only, 10k/день, заменяет Yandex который
заблокирован (1k/день demo limit исчерпан). заблокирован (1k/день demo limit исчерпан). DaData region-constraint уже
охватывает всю область (не только ЕКБ) city_hint ей не нужен.
Без кэша (дешёво, провайдеры толерируют автокомплит-запросы). Без кэша (дешёво, провайдеры толерируют автокомплит-запросы).
""" """
if not query or len(query.strip()) < 2: if not query or len(query.strip()) < 2:
@ -1131,7 +1249,9 @@ async def suggest(query: str, db: Session | None = None, limit: int = 8) -> list
# Tier 3: Yandex (legacy — оставляем как fallback, если key есть) # Tier 3: Yandex (legacy — оставляем как fallback, если key есть)
if settings.yandex_geocoder_api_key: if settings.yandex_geocoder_api_key:
try: try:
results = await _yandex_suggest(query, settings.yandex_geocoder_api_key, limit) results = await _yandex_suggest(
query, settings.yandex_geocoder_api_key, limit, city_hint=city_hint
)
if results: if results:
return results return results
except Exception: except Exception:
@ -1139,33 +1259,46 @@ async def suggest(query: str, db: Session | None = None, limit: int = 8) -> list
# Tier 4: Nominatim (последний fallback — OSM, без ключа) # Tier 4: Nominatim (последний fallback — OSM, без ключа)
try: try:
return await _nominatim_suggest(query, limit) return await _nominatim_suggest(query, limit, city_hint=city_hint)
except Exception: except Exception:
logger.exception("nominatim suggest failed") logger.exception("nominatim suggest failed")
return [] return []
# ── Public API ─────────────────────────────────────────────────────────────── # ── Public API ───────────────────────────────────────────────────────────────
async def geocode(address: str, db: Session) -> GeocodeResult | None: async def geocode(address: str, db: Session, city_hint: str | None = None) -> GeocodeResult | None:
"""Геокодинг с кэшем. Cadastral FDW → Yandex → Nominatim → None. """Геокодинг с кэшем. Cadastral FDW → Yandex → Nominatim → None.
Args: Args:
address: пользовательский ввод (может быть грязным нормализуем). address: пользовательский ввод (может быть грязным нормализуем).
db: сессия Postgres для cache lookup/write и cadastral FDW lookup. db: сессия Postgres для cache lookup/write и cadastral FDW lookup.
city_hint: город, если известен вызывающему коду (#2576) — например
выбран пользователем на предыдущем шаге UI. Опциональный, не
ломает существующий контракт. Прокидывается в Yandex/Nominatim
внешние тиры вместо молчаливой подстановки "Екатеринбург" и
участвует в cache-ключе (см. `_cache_key`), чтобы ответы для
разных городов по одному и тому же тексту адреса не перезатирали
друг друга.
Returns: Returns:
GeocodeResult или None если ни один провайдер не отвечает. GeocodeResult или None если ни один провайдер не отвечает.
`result.city_ambiguous=True`, если ни адрес, ни `city_hint` не
называли город явно итоговый город определил провайдер/локальный
тир, а не пользователь (честный сигнал, не эвристика на корректность).
""" """
if not address or len(address.strip()) < 3: if not address or len(address.strip()) < 3:
return None return None
addr_norm = normalize_address(address) _, city_specified = _resolve_city_for_geocode(address, city_hint)
city_ambiguous = not city_specified
addr_norm = _cache_key(normalize_address(address), city_hint)
# 1. Cache (sync DB-IO → offload в threadpool, чтобы не блокировать event loop) # 1. Cache (sync DB-IO → offload в threadpool, чтобы не блокировать event loop)
cached = await asyncio.to_thread(_cache_get, db, addr_norm) cached = await asyncio.to_thread(_cache_get, db, addr_norm)
if cached is not None: if cached is not None:
logger.info("geocode cache hit: %s", addr_norm) logger.info("geocode cache hit: %s", addr_norm)
return cached return replace(cached, city_ambiguous=city_ambiguous)
# 2. Локальные источники по street+house (без внешнего API). # 2. Локальные источники по street+house (без внешнего API).
parsed = _parse_street_house(address.strip()) parsed = _parse_street_house(address.strip())
@ -1191,6 +1324,7 @@ async def geocode(address: str, db: Session) -> GeocodeResult | None:
full_address=hit.full_address, full_address=hit.full_address,
provider="cache", provider="cache",
confidence="exact", confidence="exact",
city_ambiguous=city_ambiguous,
) )
await asyncio.to_thread(_cache_put, db, addr_norm, result) await asyncio.to_thread(_cache_put, db, addr_norm, result)
logger.info( logger.info(
@ -1215,6 +1349,7 @@ async def geocode(address: str, db: Session) -> GeocodeResult | None:
full_address=hit.full_address, full_address=hit.full_address,
provider="nominatim", # treat as "local" — same confidence as nominatim provider="nominatim", # treat as "local" — same confidence as nominatim
confidence="exact", confidence="exact",
city_ambiguous=city_ambiguous,
) )
await asyncio.to_thread(_cache_put, db, addr_norm, result) await asyncio.to_thread(_cache_put, db, addr_norm, result)
logger.info( logger.info(
@ -1238,6 +1373,7 @@ async def geocode(address: str, db: Session) -> GeocodeResult | None:
full_address=s.full_address, full_address=s.full_address,
provider="nominatim", # treat as "local" — same confidence as nominatim provider="nominatim", # treat as "local" — same confidence as nominatim
confidence="exact", confidence="exact",
city_ambiguous=city_ambiguous,
) )
await asyncio.to_thread(_cache_put, db, addr_norm, result) await asyncio.to_thread(_cache_put, db, addr_norm, result)
logger.info( logger.info(
@ -1248,22 +1384,26 @@ async def geocode(address: str, db: Session) -> GeocodeResult | None:
# 3. Yandex (если есть key) с typo-fallback # 3. Yandex (если есть key) с typo-fallback
if settings.yandex_geocoder_api_key: if settings.yandex_geocoder_api_key:
try: try:
result = await _yandex_lookup(address, settings.yandex_geocoder_api_key) result = await _yandex_lookup(address, settings.yandex_geocoder_api_key, city_hint)
# Если результат вне области (region 66) — пробуем typo-варианты # Если результат вне области (region 66) — пробуем typo-варианты
in_oblast = result is not None and is_within_oblast66_bbox(result.lat, result.lon) in_oblast = result is not None and is_within_oblast66_bbox(result.lat, result.lon)
if result is not None and in_oblast: 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) await asyncio.to_thread(_cache_put, db, addr_norm, result)
logger.info("geocode yandex: %s → (%.5f, %.5f)", addr_norm, result.lat, result.lon) logger.info("geocode yandex: %s → (%.5f, %.5f)", addr_norm, result.lat, result.lon)
return result return result
# Tier 2: typo-variants # Tier 2: typo-variants
for variant in _typo_variants(address, limit=4): for variant in _typo_variants(address, limit=4):
try: try:
result = await _yandex_lookup(variant, settings.yandex_geocoder_api_key) result = await _yandex_lookup(
variant, settings.yandex_geocoder_api_key, city_hint
)
except Exception: except Exception:
continue continue
if result is None: if result is None:
continue continue
if is_within_oblast66_bbox(result.lat, result.lon): 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) await asyncio.to_thread(_cache_put, db, addr_norm, result)
logger.info( logger.info(
"geocode yandex typo-fixed: %s%s → (%.5f, %.5f)", "geocode yandex typo-fixed: %s%s → (%.5f, %.5f)",
@ -1280,6 +1420,7 @@ async def geocode(address: str, db: Session) -> GeocodeResult | None:
try: try:
result = await _nominatim_lookup(address) result = await _nominatim_lookup(address)
if result is not None: if result is not None:
result = replace(result, city_ambiguous=city_ambiguous)
await asyncio.to_thread(_cache_put, db, addr_norm, result) await asyncio.to_thread(_cache_put, db, addr_norm, result)
logger.info("geocode nominatim: %s → (%.5f, %.5f)", addr_norm, result.lat, result.lon) logger.info("geocode nominatim: %s → (%.5f, %.5f)", addr_norm, result.lat, result.lon)
# Nominatim rate-limit policy: 1 req/sec — спим после успешного запроса # Nominatim rate-limit policy: 1 req/sec — спим после успешного запроса

View file

@ -0,0 +1,413 @@
"""Тесты #2576 — geocoder больше НЕ подставляет "Екатеринбург" молча.
Проблема (issue #2576 / эпик расширения на область): `_yandex_lookup`,
`_yandex_suggest`, `_nominatim_suggest` при отсутствии маркера города/области в
самом адресе всегда молча подставляли "Екатеринбург" житель Нижнего Тагила,
вводя «Ленина, 1», получал уверенно неверную цену по екатеринбургской улице
Ленина (обе улицы называются одинаково).
Покрывают:
- `_resolve_city_for_geocode` приоритет: маркер в адресе > `city_hint` > None.
- `_yandex_lookup` без города НЕ получает "Екатеринбург, "-префикс и bias
смещён на всю область (не форсит ЕКБ-центр); с `city_hint` префикс из hint'а;
с явным "Екатеринбург" в адресе поведение НЕ изменилось (как раньше).
- `_yandex_suggest` (Tier 1 bounded) то же самое, плюс bias/rspn.
- `_nominatim_suggest` то же самое (суффикс города, не префикс).
- `geocode()` `city_ambiguous=True` когда город не указан ни в адресе, ни в
`city_hint`; `False` когда указан явно (текстом или через `city_hint`).
- Cache-ключ (`_cache_key`) разные `city_hint` для одного текста адреса НЕ
делят одну запись кэша (regression test на cache poisoning).
"""
from __future__ import annotations
import contextlib
import os
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,
_cache_key,
_nominatim_suggest,
_resolve_city_for_geocode,
_yandex_lookup,
_yandex_suggest,
geocode,
)
# ── _resolve_city_for_geocode ────────────────────────────────────────────────
@pytest.mark.parametrize(
"address,city_hint,expected",
[
# Ни маркер, ни hint — раньше здесь молча подставлялся "Екатеринбург".
("Ленина, 1", None, (None, False)),
("Ленина, 1", "", (None, False)),
("Ленина, 1", " ", (None, False)),
# city_hint передан явно вызывающим кодом.
("Ленина, 1", "Нижний Тагил", ("Нижний Тагил", True)),
# Маркер уже в адресе — hint игнорируется (marker имеет приоритет).
("Нижний Тагил, Ленина, 1", "Серов", (None, True)),
("Екатеринбург, Малышева 30", None, (None, True)),
("Екатеринбург, Малышева 30", "Серов", (None, True)),
],
)
def test_resolve_city_for_geocode(
address: str, city_hint: str | None, expected: tuple[str | None, bool]
) -> None:
assert _resolve_city_for_geocode(address, city_hint) == expected
# ── _cache_key — cache poisoning между городами ──────────────────────────────
def test_cache_key_without_hint_unchanged() -> None:
"""Без city_hint формат ключа НЕ меняется — backward-compat с накопленным кэшем."""
assert _cache_key("ленина, 1", None) == "ленина, 1"
assert _cache_key("ленина, 1", "") == "ленина, 1"
def test_cache_key_different_cities_do_not_collide() -> None:
"""#2576: разные города для одного текста адреса — разные cache-ключи."""
key_tagil = _cache_key("ленина, 1", "Нижний Тагил")
key_ekb = _cache_key("ленина, 1", "Екатеринбург")
key_none = _cache_key("ленина, 1", None)
assert key_tagil != key_ekb
assert key_tagil != key_none
assert key_ekb != key_none
def test_cache_key_hint_normalized() -> None:
"""city_hint нормализуется (case/whitespace) — не создаёт лишних ключей."""
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 ───────────────────────────────────────────────────────
async def test_nominatim_suggest_no_city_no_suffix() -> None:
"""#2576: без города — Nominatim-запрос БЕЗ ", Екатеринбург"-суффикса.
Географию не расширяет/не сужает (`_nominatim_query_multi` уже bounded=1
по всей области `OBLAST66_VIEWBOX`) но суффикс раньше смещал ранжирование
Nominatim в пользу ЕКБ-совпадений для адресов из других городов области.
NB: с пустым результатом (как здесь) `_nominatim_suggest` уходит дальше в
typo-fallback Tier 2 (несколько доп. вызовов) берём ПЕРВЫЙ вызов (Tier 1,
оригинальный query), не последний.
"""
calls: list[str] = []
async def fake_query_multi(client, query, limit):
calls.append(query)
return []
with patch(
"app.services.geocoder._nominatim_query_multi", new=AsyncMock(side_effect=fake_query_multi)
):
result = await _nominatim_suggest("Ленина, 1")
assert result == []
assert calls[0] == "Ленина, 1"
assert "Екатеринбург" not in calls[0]
async def test_nominatim_suggest_city_hint_suffix() -> None:
calls: list[str] = []
async def fake_query_multi(client, query, limit):
calls.append(query)
return []
with patch(
"app.services.geocoder._nominatim_query_multi", new=AsyncMock(side_effect=fake_query_multi)
):
await _nominatim_suggest("Ленина, 1", city_hint="Нижний Тагил")
assert calls[0] == "Ленина, 1, Нижний Тагил"
async def test_nominatim_suggest_explicit_ekb_unchanged() -> None:
calls: list[str] = []
async def fake_query_multi(client, query, limit):
calls.append(query)
return []
with patch(
"app.services.geocoder._nominatim_query_multi", new=AsyncMock(side_effect=fake_query_multi)
):
await _nominatim_suggest("Екатеринбург, Малышева 30")
assert calls[0] == "Екатеринбург, Малышева 30"
# ── geocode() — city_ambiguous flag ──────────────────────────────────────────
def _geocode_patches(yandex_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)),
)
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)
assert result is not None
assert result.city_ambiguous is True
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"
)
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)
assert result is not None
assert result.city_ambiguous is False
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"
)
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="Нижний Тагил")
assert result is not None
assert result.city_ambiguous is False
# ── geocode() — cache не смешивает города ────────────────────────────────────
async def test_geocode_cache_does_not_mix_cities() -> None:
"""#2576 regression: два города для одного текста адреса не делят cache-запись.
Без city_hint-aware ключа второй вызов (Тагил) читал бы уже закэшированный
(первым вызовом, ЕКБ) результат координаты ЕКБ вместо Тагила.
"""
store: dict[str, GeocodeResult] = {}
def fake_cache_get(db, addr_norm):
return store.get(addr_norm)
def fake_cache_put(db, addr_norm, result):
store[addr_norm] = result
async def fake_yandex_lookup(address, api_key, city_hint=None):
if city_hint == "Нижний Тагил":
return GeocodeResult(
lat=57.905, lon=59.950, full_address="Нижний Тагил, Ленина, 1", provider="yandex"
)
return GeocodeResult(
lat=56.838, lon=60.605, full_address="Екатеринбург, Ленина, 1", provider="yandex"
)
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),
)
)
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)
assert r_tagil.lat == pytest.approx(57.905)
assert r_ekb_again.lat == pytest.approx(56.838)
assert r_ekb.lat != r_tagil.lat
# Два разных ключа реально осели в fake-store (не перезаписали друг друга).
assert len(store) == 2