gendesign/tradein-mvp/backend/app/api/v1/geocode.py
bot-backend 4b1a282234
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
fix(tradein/geocoder): не подставлять Екатеринбург молча — явный city_hint (#2576)
Раньше _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.
2026-07-31 16:42:29 +03:00

155 lines
6.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

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

"""Geocode endpoints — debug + frontend Suggest proxy."""
from __future__ import annotations
import logging
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from app.core.db import get_db
from app.services.geocoder import GeocodeResult, geocode, reverse_geocode, suggest
logger = logging.getLogger(__name__)
router = APIRouter()
@router.get("/lookup", response_model=GeocodeResult)
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, city_hint=city_hint)
if result is None:
raise HTTPException(status_code=404, detail=f"Address not found: {address}")
return result
class SuggestItem(BaseModel):
label: str
full_address: str
lat: float
lon: float
kind: str
# ГАР OBJECTGUID (ФИАС) дома — присутствует ТОЛЬКО у house-level кандидатов
# DaData-тира. У прочих тиров / street/locality-кандидатов null. Фронт
# прокидывает его в POST /estimate как target_fias_id (Tier 0.5 fias_exact).
fias_id: str | None = None
class SuggestResponse(BaseModel):
items: list[SuggestItem]
@router.get("/suggest", response_model=SuggestResponse)
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).
Используется в EstimateForm для подсказок пока пользователь печатает.
Bounded viewbox — генеральный по всей области (см. geocoder.OBLAST66_VIEWBOX),
ЕКБ (lon 60.40-60.85, lat 56.65-56.95) внутри него остаётся быстрым fast-path.
Пример:
/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, city_hint=city_hint)
return SuggestResponse(
items=[
SuggestItem(
label=s.label,
full_address=s.full_address,
lat=s.lat,
lon=s.lon,
kind=s.kind,
fias_id=s.fias_id,
)
for s in items
]
)
class ReverseResponse(BaseModel):
"""Reverse-geocode response для MapPicker'а.
`lat`/`lon` — echo входной точки клика (для логики «отодвинули ли далеко»).
`snapped_lat`/`snapped_lon` — центр matched здания от provider'а. Если
`precision in ("exact","number")` фронт двигает marker на snapped point —
пользователь видит «магнит к дому». Для остальных precision snapped == input.
"""
address: str
lat: float = Field(..., description="Исходная latitude клика")
lon: float = Field(..., description="Исходная longitude клика")
snapped_lat: float = Field(..., description="Latitude центра matched здания")
snapped_lon: float = Field(..., description="Longitude центра matched здания")
precision: str = Field(
...,
description=(
"Yandex-style: exact/number/street/range/near/locality/other/cadastral. "
"Фронт двигает marker только если exact/number/cadastral."
),
)
provider: str = Field(..., description="cadastral | yandex | nominatim")
@router.get("/reverse", response_model=ReverseResponse)
async def reverse(
lat: Annotated[float, Query(ge=-90, le=90)],
lon: Annotated[float, Query(ge=-180, le=180)],
db: Annotated[Session, Depends(get_db)] = None, # type: ignore[assignment]
) -> ReverseResponse:
"""Обратный геокодинг — координаты с карты → адрес + snapped точка здания.
Пример: /api/v1/geocode/reverse?lat=56.8389&lon=60.6057
"""
result = await reverse_geocode(lat, lon, db=db)
if result is None:
raise HTTPException(status_code=404, detail="address not found for coordinates")
return ReverseResponse(
address=result.address,
lat=lat,
lon=lon,
snapped_lat=result.snapped_lat,
snapped_lon=result.snapped_lon,
precision=result.precision,
provider=result.provider,
)