gendesign/tradein-mvp/backend/app/api/v1/geocode.py
bot-backend 690f1ef5d2
All checks were successful
CI / backend-tests (pull_request) Successful in 17m59s
CI Trade-In / changes (pull_request) Successful in 10s
CI / changes (pull_request) Successful in 12s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 2m18s
CI Trade-In / backend-tests (pull_request) Successful in 5m59s
feat(metrics): продуктовые счётчики Prometheus для Меры и Птицы + дашборд
Владелец попросил вывести продукт в Графану — до этого там были только
технические панели (запросы/латентность/память). Список счётчиков взят из
реально пишущихся событий, а не выдуман:

Мера (tradein-mvp/backend/app/observability/metrics.py):
- mera_estimates_total{outcome=ok|insufficient_data} — POST /estimate,
  зеркалит user_events.event_type=estimate_request (294 строки в БД),
  insufficient_data — не ошибка, а исход без аналогов.
- mera_address_suggestions_total{found=yes|no} — GET /geocode/suggest,
  своего user_events-события у ручки не было.
- mera_reports_exported_total (без лейблов) — GET /estimate/{id}/pdf.
- mera_leads_total (без лейблов) — POST /trade-in/lead.
- mera_support_messages_total{channel=web|anon} — POST /support/messages
  и /support/anon/messages, счётчик после успешной доставки в Telegram.
- mera_logins_total{result=success|failed} — рядом с user_events
  login_success/login_failed в auth.py (97/453 строк в БД).

Птица (backend/app/observability/metrics.py):
- sitefinder_reports_exported_total{format} — GET .../forecast/export
  (md/json/tg/docx/pptx/pdf) и POST .../best-layouts/pdf.

Метки везде — фиксированный литерал из места вызова (outcome/found/channel/
result/format), никогда username/адрес/estimate_id/кадастровый номер —
это ровно то, что взрывает кардинальность ряда у Prometheus.

Дашборд ops/metrics/grafana/dashboards/product.json ("Продуктовые метрики",
uid gendesign-product) — воронка Меры (оценки/подсказки/лиды/отчёты/входы/
поддержка) + экспорт форматов Птицы, часовые increase()-панели без
стекирования (на соседней панели оно уже давало ложную тревогу, PR #3474).
Provisioning тот же, что у apps.json — сканирует директорию, отдельного
конфига не нужно.

ops/metrics/alloy/alloy-apps.alloy проверен: у job "apps" нет relabel-
фильтра по __name__ (в отличие от cadvisor) — новые счётчики уходят в
remote_write как есть, правки не потребовалось.

Refs #3471
2026-09-12 14:22:33 +03:00

297 lines
13 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 import text
from sqlalchemy.orm import Session
from app.core.db import get_db, run_db_thread
from app.observability.metrics import ADDRESS_SUGGESTIONS
from app.services.estimator import _lookup_house_facts
from app.services.geocoder import GeocodeResult, geocode, reverse_geocode, suggest
from app.services.regions import DEFAULT_REGION_CODE, region_by_city
logger = logging.getLogger(__name__)
router = APIRouter()
def effective_region_code(region_code: int | None, city_hint: str | None) -> int:
"""Регион для геокодера (#3051): явный `region_code` > вывод из `city_hint` > 66.
До этого хелпера `/suggest` всегда уходил в геокодер с регионом 66
по умолчанию — московский `city_hint` («Москва») молча получал
свердловский bbox-констрейнт и терял подсказки. Реестр `app.services.regions`
уже знает, каким городам какой регион соответствует (REGIONS[77].cities
содержит «москва») — используем его вместо повторного захардкоженного списка.
"""
if region_code is not None:
return region_code
region = region_by_city(city_hint)
if region is not None:
return region.code
return DEFAULT_REGION_CODE
@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,
region_code: Annotated[
int | None,
Query(
description=(
"Регион покрытия (#3051). None (дефолт) — выводится из `city_hint` "
"через реестр регионов, иначе 66 (Свердловская область, прежнее "
"поведение). 77 — Москва: без него DaData и Nominatim получают "
"свердловский hard-констрейнт и молча возвращают ПУСТО на "
"московском адресе."
),
),
] = None,
) -> SuggestResponse:
"""Автокомплит адресов в регионе `region_code` (дефолт 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=Нижний+Тагил
/api/v1/geocode/suggest?q=Тверская+6&region_code=77 # Москва
"""
resolved_region_code = effective_region_code(region_code, city_hint)
try:
items = await suggest(
q, db=db, limit=limit, city_hint=city_hint, region_code=resolved_region_code
)
except ValueError as exc:
# Регион вне реестра покрытия — 422, а не 500: это ошибка ввода клиента.
raise HTTPException(status_code=422, detail=str(exc)) from exc
ADDRESS_SUGGESTIONS.labels(found="yes" if items else "no").inc()
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=(
"exact/number/street/range/near/locality/other/cadastral. "
"Фронт двигает marker только если exact/number/cadastral."
),
)
provider: str = Field(..., description="cadastral | 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,
)
class HouseFactsResponse(BaseModel):
"""Ответ /house-facts."""
found: bool
total_floors: int | None = None
year_built: int | None = None
house_type: str | None = None
source: str | None = Field(
default=None, description="'houses' когда факты найдены в справочнике, иначе None"
)
def _resolve_house_id_by_fias(db: Session, fias_id: str) -> int | None:
"""Ищет id дома в `houses` по ФИАС/ГАР guid — по трём алиасам guid-полей
справочника (заполняются из разных источников загрузки: DOM.РФ/ГИС ЖКХ/ГАР).
Best-effort: не нашли — вызывающий молча падает на geo-фолбэк по lat/lon.
"""
try:
row = (
db.execute(
text(
"""
SELECT id FROM houses
WHERE gar_house_guid = CAST(:g AS text)
OR house_fias_id = CAST(:g AS text)
OR zhkh_house_guid = CAST(:g AS text)
LIMIT 1
"""
),
{"g": fias_id},
)
.mappings()
.first()
)
except Exception as exc: # pragma: no cover — defensive
# Тот же best-effort, что и у _lookup_house_facts ниже по цепочке:
# предзаполнение формы НЕ должно ронять запрос в 500. Откатываем
# транзакцию, чтобы не отравить сессию для последующего geo-фолбэка.
logger.warning("house-facts: fias lookup failed (graceful): %s", exc)
try:
db.rollback()
except Exception: # pragma: no cover — defensive
pass
return None
if row is None:
return None
house_id = row["id"]
return house_id if isinstance(house_id, int) else None
@router.get("/house-facts", response_model=HouseFactsResponse)
async def house_facts(
lat: Annotated[float, Query(ge=-90, le=90)],
lon: Annotated[float, Query(ge=-180, le=180)],
db: Annotated[Session, Depends(get_db)],
fias_id: Annotated[str | None, Query(max_length=64)] = None,
) -> HouseFactsResponse:
"""Предзаполнение формы оценки (этажность/год/тип дома) из справочника `houses`.
Покрытие справочника: total_floors — 98.3%, year_built — 86.8% (прод, 2026-08).
Переиспользует ту же логику поиска, что и estimate_quality() (#3234): по
target_house_id если резолвился ФИАС, иначе ближайший дом в радиусе 60м от
lat/lon (ST_DWithin).
Отдельная ручка, а не поле в /suggest: suggest дёргается на КАЖДОЕ нажатие
клавиши автокомплита (debounced, но всё равно несколько запросов на ввод
адреса) — тащить туда ещё один запрос к houses на каждый из 8 кандидатов
на каждый keystroke было бы неоправданной нагрузкой на БД. /house-facts
вызывается ОДИН раз, после того как пользователь выбрал конкретный адрес
из подсказок.
found=false (200, НЕ 404) означает «дома нет в справочнике» — это не
ошибка запроса, а честный ответ об отсутствии данных.
Пример:
/api/v1/geocode/house-facts?lat=56.838&lon=60.595
/api/v1/geocode/house-facts?lat=56.838&lon=60.595&fias_id=...
"""
target_house_id: int | None = None
if fias_id is not None:
target_house_id = await run_db_thread(_resolve_house_id_by_fias, db, fias_id)
facts = await run_db_thread(
_lookup_house_facts,
db,
target_house_id=target_house_id,
lat=lat,
lon=lon,
)
if facts is None:
return HouseFactsResponse(found=False)
return HouseFactsResponse(
found=True,
total_floors=facts.total_floors,
year_built=facts.year_built,
house_type=facts.house_type,
source="houses",
)