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

View file

@ -27,6 +27,12 @@ class TradeInEstimateInput(BaseModel):
# geocode() (который падает на DaData-формах при мёртвом Yandex-ключе).
lat: float | None = Field(default=None, ge=-90, le=90)
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
# (SuggestItem.fias_id у house-level кандидата). Прокидывается в матчер
# (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_lat: 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']
data_freshness_minutes: int | None = None # сколько минут назад был самый свежий парсинг
# абсолютный timestamp самого свежего парсинга аналогов

View file

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

View file

@ -16,7 +16,7 @@ from __future__ import annotations
import asyncio
import logging
import re
from dataclasses import dataclass
from dataclasses import dataclass, replace
from typing import Literal
import httpx
@ -38,6 +38,12 @@ class GeocodeResult:
full_address: str
provider: Literal["nominatim", "yandex", "cache"]
confidence: Literal["exact", "approximate", "locality"] = "approximate"
# #2576: True если город НЕ был указан пользователем (ни в тексте адреса, ни
# через `city_hint`) — т.е. итоговый город результата определил провайдер
# (или локальный ЕКБ-тир), а не вызывающий код. Не эвристика на «правильность»
# результата — честный сигнал «доверяй, но проверяй», чтобы вызывающий код мог
# понизить confidence / переспросить город у пользователя. См. `_resolve_city_for_geocode`.
city_ambiguous: bool = False
# ── EKB bounding boxes ───────────────────────────────────────────────────────
@ -191,6 +197,53 @@ def _has_oblast_marker(text_lower: str) -> bool:
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
# локальные тиры (geoportal/cadastral, см. `geocode()`) от адреса другого
# города области. re.escape на элементах SVERDLOVSK_OBLAST_CITIES-{ekb}.
@ -240,6 +293,29 @@ def normalize_address(address: str) -> str:
return " ".join(address.lower().strip().split())
def _cache_key(address_norm: str, city_hint: str | None) -> str:
"""Ключ `geocode_cache.address_normalized` — адрес, дополненный городом,
если он известен вызывающему коду.
#2576: раньше ключ был просто нормализованный адрес — одинаковый для
«Ленина, 1» независимо от того, кто спрашивает (ЕКБ или Нижний Тагил).
Т.к. геокодер раньше молча предполагал ЕКБ, оба города писали/читали ОДНУ
и ту же строку кэша взаимная порча (первый запрос «застолбил» город для
второго). С `city_hint` разные города для одного текста адреса больше не
делят один ключ.
БЕЗ `city_hint` формат ключа не меняется (backward-compatible с уже
накопленным кэшем) коллизия между городами для запросов без hint'а
остаётся возможной (структурно неизбежно, пока вызывающий код не начнёт
передавать city_hint повсеместно), но `city_ambiguous` на результате
честно сигнализирует об этом вызывающему.
"""
city_norm = " ".join((city_hint or "").lower().strip().split())
if not city_norm:
return address_norm
return f"{address_norm}|city={city_norm}"
# Согласные, которые часто пишут с одной буквой вместо двух (RU typos).
_DOUBLE_CONSONANTS = "лнмссккттпп"
@ -451,17 +527,24 @@ def _yandex_region_ok(geo_object: dict) -> bool | None:
# ── 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) -> GeocodeResult | None:
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 (центр ЕКБ) для приоритизации местных результатов,
но БЕЗ rspn чтобы fuzzy matching работал при опечатках.
Запрашиваем с ll+spn (центр ЕКБ, если контекст ЕКБ, иначе центр всей
области см. `_yandex_bias`) для приоритизации местных результатов, но
БЕЗ rspn чтобы fuzzy matching работал при опечатках.
"""
# Не навязываем "Екатеринбург, " если в адресе уже есть город/область региона 66
# (типичный кейс из suggest, либо явный запрос по другому городу области).
geocode_query = address if _has_oblast_marker(address.lower()) else f"Екатеринбург, {address}"
# Город в запрос подставляем ТОЛЬКО если он известен (адрес уже называет
# город/область региона 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/",
@ -471,8 +554,8 @@ async def _yandex_lookup(address: str, api_key: str) -> GeocodeResult | None:
"format": "json",
"results": 5, # берем top-5, отфильтруем по ЕКБ bbox ниже
"lang": "ru_RU",
"ll": EKB_BBOX["ll"],
"spn": EKB_BBOX["spn"],
"ll": bias["ll"],
"spn": bias["spn"],
},
)
response.raise_for_status()
@ -641,17 +724,27 @@ async def _dadata_suggest(query: str, limit: int = 8) -> list[GeocodeSuggestion]
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]:
"""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] = {
"apikey": api_key,
"geocode": query,
"format": "json",
"results": str(limit),
"lang": "ru_RU",
"ll": EKB_BBOX["ll"],
"spn": EKB_BBOX["spn"],
"ll": b["ll"],
"spn": b["spn"],
}
if bounded:
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))
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 режим.
Tier 1: bounded ЕКБ (rspn=1) быстрый путь для основного (ЕКБ) трафика.
Tier 2: bounded ЕКБ на typo-variants (удвоение согласных).
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 строго ЕКБ) структурно вернуть не могут.
(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:
# Tier 1: strict bounded на оригинал (ЕКБ fast path)
# 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-варианты (тот же ЕКБ fast path)
# Tier 2: bounded на typo-варианты (тот же bias)
for variant in _typo_variants(query, limit=4):
variant_query = (
variant if _has_oblast_marker(variant.lower()) else f"Екатеринбург, {variant}"
)
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:
@ -708,6 +811,7 @@ async def _yandex_suggest(query: str, api_key: str, limit: int = 8) -> list[Geoc
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)]
@ -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))
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 недоступен).
Суффикс ", Екатеринбург" навязывается ТОЛЬКО если в запросе ещё нет города/области
региона 66 иначе не режем явные запросы по другим городам области.
Суффикс города навязывается ТОЛЬКО если он известен: адрес уже называет
город/область региона 66, либо передан явный `city_hint`. Раньше (#2576)
при отсутствии обоих сюда молча подставлялся суффикс ", Екатеринбург"
географию поиска это не расширяло/не сужало (`_nominatim_query_multi` и
так bounded=1 по ВСЕЙ области `OBLAST66_VIEWBOX`), но текстовый суффикс
смещал ранжирование Nominatim в пользу ЕКБ-совпадений даже для адресов
из других городов области.
"""
headers = {
"User-Agent": f"TradeInMVP/0.1 (contact: {settings.contact_email})",
"Accept": "application/json",
"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:
# Tier 1: оригинальный query
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:
for variant in _typo_variants(query, limit=3):
await asyncio.sleep(1.0) # Nominatim 1 req/sec
variant_query = (
variant if _has_oblast_marker(variant.lower()) else f"{variant}, Екатеринбург"
)
variant_city, _ = _resolve_city_for_geocode(variant, city_hint)
variant_query = f"{variant}, {variant_city}" if variant_city else variant
data = await _nominatim_query_multi(client, variant_query, limit)
if data:
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)
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; ЕКБ — основной трафик,
остаётся быстрым fast-path). Cadastral FDW DaData Yandex Nominatim [].
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 который
заблокирован (1k/день demo limit исчерпан).
заблокирован (1k/день demo limit исчерпан). DaData region-constraint уже
охватывает всю область (не только ЕКБ) city_hint ей не нужен.
Без кэша (дешёво, провайдеры толерируют автокомплит-запросы).
"""
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 есть)
if settings.yandex_geocoder_api_key:
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:
return results
except Exception:
@ -1139,33 +1259,46 @@ async def suggest(query: str, db: Session | None = None, limit: int = 8) -> list
# Tier 4: Nominatim (последний fallback — OSM, без ключа)
try:
return await _nominatim_suggest(query, limit)
return await _nominatim_suggest(query, limit, city_hint=city_hint)
except Exception:
logger.exception("nominatim suggest failed")
return []
# ── 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.
Args:
address: пользовательский ввод (может быть грязным нормализуем).
db: сессия Postgres для cache lookup/write и cadastral FDW lookup.
city_hint: город, если известен вызывающему коду (#2576) — например
выбран пользователем на предыдущем шаге UI. Опциональный, не
ломает существующий контракт. Прокидывается в Yandex/Nominatim
внешние тиры вместо молчаливой подстановки "Екатеринбург" и
участвует в cache-ключе (см. `_cache_key`), чтобы ответы для
разных городов по одному и тому же тексту адреса не перезатирали
друг друга.
Returns:
GeocodeResult или None если ни один провайдер не отвечает.
`result.city_ambiguous=True`, если ни адрес, ни `city_hint` не
называли город явно итоговый город определил провайдер/локальный
тир, а не пользователь (честный сигнал, не эвристика на корректность).
"""
if not address or len(address.strip()) < 3:
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)
cached = await asyncio.to_thread(_cache_get, db, addr_norm)
if cached is not None:
logger.info("geocode cache hit: %s", addr_norm)
return cached
return replace(cached, city_ambiguous=city_ambiguous)
# 2. Локальные источники по street+house (без внешнего API).
parsed = _parse_street_house(address.strip())
@ -1191,6 +1324,7 @@ async def geocode(address: str, db: Session) -> GeocodeResult | None:
full_address=hit.full_address,
provider="cache",
confidence="exact",
city_ambiguous=city_ambiguous,
)
await asyncio.to_thread(_cache_put, db, addr_norm, result)
logger.info(
@ -1215,6 +1349,7 @@ async def geocode(address: str, db: Session) -> GeocodeResult | None:
full_address=hit.full_address,
provider="nominatim", # treat as "local" — same confidence as nominatim
confidence="exact",
city_ambiguous=city_ambiguous,
)
await asyncio.to_thread(_cache_put, db, addr_norm, result)
logger.info(
@ -1238,6 +1373,7 @@ async def geocode(address: str, db: Session) -> GeocodeResult | None:
full_address=s.full_address,
provider="nominatim", # treat as "local" — same confidence as nominatim
confidence="exact",
city_ambiguous=city_ambiguous,
)
await asyncio.to_thread(_cache_put, db, addr_norm, result)
logger.info(
@ -1248,22 +1384,26 @@ async def geocode(address: str, db: Session) -> GeocodeResult | None:
# 3. Yandex (если есть key) с typo-fallback
if settings.yandex_geocoder_api_key:
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-варианты
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)
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)",
@ -1280,6 +1420,7 @@ async def geocode(address: str, db: Session) -> GeocodeResult | None:
try:
result = await _nominatim_lookup(address)
if result is not None:
result = replace(result, city_ambiguous=city_ambiguous)
await asyncio.to_thread(_cache_put, db, addr_norm, result)
logger.info("geocode nominatim: %s → (%.5f, %.5f)", addr_norm, result.lat, result.lon)
# 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