gendesign/tradein-mvp/backend/app/services/dadata.py
Light1YT 386fa8c0e3 feat(tradein): DaData /suggest tier в /suggest endpoint (PR Q2)
Yandex Suggest заблокирован (демо 1k/день исчерпан) — DaData /suggest/address
(10k/день free, token-only auth, лучшее покрытие РФ) заменяет его как Tier 2
после Cadastral FDW. Yandex остаётся как Tier 3 fallback.

- app/services/dadata.py: DadataSuggestion dataclass + async suggest_addresses()
  (token-only, без X-Secret; graceful [] на все ошибки; 5s timeout;
  classifies fias_level→kind: 8=house, 7=street, ≤6=city; count clamp ≤20)
- app/services/geocoder.py: _dadata_suggest() обёртка, дропает entries без
  координат; suggest() теперь Cadastral → DaData → Yandex → Nominatim
- tests/services/test_dadata.py: 15 новых unit-тестов (happy + все error paths)
- tests/services/test_cadastral_reverse.py: +тест DaData до Yandex, update
  fallback теста под новый tier chain

Tests: 62 passed (35 dadata + 20 cadastral reverse + 7 geocode reverse api).
Ruff clean. Reuse DADATA_API_TOKEN from PR Q1 (#604) — secret не нужен для suggest.
2026-05-27 19:08:58 +05:00

383 lines
14 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.

"""DaData clients — /clean/address (enrichment) + /suggest/address (autocomplete).
/clean/address — обогащение **целевого адреса** канонической формой,
kadastr_num, ФИАС, координаты, метро. Используется в estimator flow для
on-demand обогащения payload.address (PR Q1). Требует token + secret,
demo tier 100/день.
/suggest/address — автокомплит для /api/v1/geocode/suggest (PR Q2).
Заменяет заблокированный Yandex Suggest как второй tier после Cadastral FDW.
Token-only (X-Secret не нужен), 10k/день free. Возвращает список candidate'ов
с координатами + ФИАС + структурными полями.
ENV: DADATA_API_TOKEN, DADATA_API_SECRET.
- clean_address: требует оба.
- suggest_addresses: требует только DADATA_API_TOKEN.
Если требуемые credentials не заданы → graceful возврат None / [] (не break flow).
Docs:
- https://dadata.ru/api/clean/address/
- https://dadata.ru/api/suggest/address/
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import Any
import httpx
from app.core.config import settings
logger = logging.getLogger(__name__)
DADATA_CLEAN_URL = "https://cleaner.dadata.ru/api/v1/clean/address"
DADATA_SUGGEST_URL = "https://suggestions.dadata.ru/suggestions/api/4_1/rs/suggest/address"
_DADATA_TIMEOUT_S = 8.0
_DADATA_SUGGEST_TIMEOUT_S = 5.0
@dataclass(frozen=True, slots=True)
class DadataAddressResult:
"""Parsed DaData enrichment payload — slots для compact representation.
canonical_address — поле `result` из ответа («г Екатеринбург, ул Малышева, д 125»).
house_cadnum — кадастровый номер ДОМА (не участка). Формат «66:41:0704045:350».
house_fias_id — UUID ФИАС дома.
lat / lon — координаты от DaData (precision до дома).
qc_geo — quality code геокодинга: 0=exact, 1=street, 2=settlement…
qc_house — quality code дома: 2=в ФИАС, 10=на картах…
kladr_id — старый ОКРС-классификатор (legacy, может быть полезен).
okato / oktmo — admin codes для отчётности.
metro — список ближайших станций метро (если есть): [{name,line,distance}…].
raw — полный ответ для debugging / future fields.
"""
canonical_address: str | None
house_cadnum: str | None
house_fias_id: str | None
lat: float | None
lon: float | None
qc_geo: int | None
qc_house: int | None
kladr_id: str | None
okato: str | None
oktmo: str | None
metro: list[dict[str, Any]]
raw: dict[str, Any]
def _credentials() -> tuple[str, str] | None:
"""Returns (token, secret) если оба заданы, иначе None."""
token = (settings.dadata_api_token or "").strip()
secret = (settings.dadata_api_secret or "").strip()
if not token or not secret:
return None
return token, secret
def _coerce_int(value: Any) -> int | None:
"""DaData отдаёт qc-коды строками иногда. Coerce → int безопасно."""
if value is None:
return None
try:
return int(value)
except (TypeError, ValueError):
return None
def _coerce_float(value: Any) -> float | None:
if value is None:
return None
try:
return float(value)
except (TypeError, ValueError):
return None
def _parse_response(item: dict[str, Any]) -> DadataAddressResult:
"""Парсит single DaData payload item в DadataAddressResult.
Не raise'ит — пропускает невалидные поля как None.
"""
metro_raw = item.get("metro") or []
if not isinstance(metro_raw, list):
metro_raw = []
return DadataAddressResult(
canonical_address=item.get("result"),
house_cadnum=item.get("house_cadnum"),
house_fias_id=item.get("house_fias_id"),
lat=_coerce_float(item.get("geo_lat")),
lon=_coerce_float(item.get("geo_lon")),
qc_geo=_coerce_int(item.get("qc_geo")),
qc_house=_coerce_int(item.get("qc_house")),
kladr_id=item.get("kladr_id"),
okato=item.get("okato"),
oktmo=item.get("oktmo"),
metro=metro_raw,
raw=item,
)
async def clean_address(address: str) -> DadataAddressResult | None:
"""Обогащает один адрес через DaData /clean/address.
Args:
address: свободный текст адреса (как пришёл от пользователя или из geocoder).
Returns:
DadataAddressResult или None если:
- credentials не заданы в ENV (graceful disable)
- адрес пустой / слишком короткий
- API вернул пустой массив / ошибку shape
- сетевая / HTTP ошибка (5xx, 429 quota, timeout)
- адрес не распознан (canonical_address пустой)
"""
if not address or len(address.strip()) < 3:
return None
creds = _credentials()
if creds is None:
logger.debug("dadata: credentials не заданы — skip enrichment")
return None
token, secret = creds
headers = {
"Authorization": f"Token {token}",
"X-Secret": secret,
"Content-Type": "application/json",
"Accept": "application/json",
}
body = [address.strip()]
try:
async with httpx.AsyncClient(timeout=_DADATA_TIMEOUT_S) as client:
response = await client.post(DADATA_CLEAN_URL, headers=headers, json=body)
except (httpx.TimeoutException, httpx.NetworkError) as exc:
logger.warning("dadata: network error для %r: %s", address[:60], exc)
return None
except Exception as exc: # pragma: no cover — defensive
logger.warning("dadata: unexpected client error для %r: %s", address[:60], exc)
return None
status = response.status_code
if status == 429:
logger.warning("dadata: HTTP 429 — quota exceeded (100/день demo limit?)")
return None
if status in (401, 403):
logger.error(
"dadata: HTTP %d — auth/secret rejected. Проверь DADATA_API_TOKEN/SECRET.",
status,
)
return None
if status >= 500:
logger.warning("dadata: HTTP %d — transient server error", status)
return None
if status >= 400:
body_preview = (response.text or "")[:200]
logger.warning("dadata: HTTP %d — bad request: %r", status, body_preview)
return None
try:
payload = response.json()
except ValueError as exc:
logger.warning("dadata: невалидный JSON в ответе: %s", exc)
return None
if not isinstance(payload, list) or not payload:
logger.info("dadata: пустой массив для %r", address[:60])
return None
item = payload[0]
if not isinstance(item, dict):
logger.warning("dadata: первый элемент не dict (%s)", type(item).__name__)
return None
result = _parse_response(item)
if not result.canonical_address:
# DaData вернул shape, но не распознал адрес — quality codes обычно qc_geo=5.
logger.info(
"dadata: адрес не распознан (qc_geo=%s qc_house=%s) для %r",
result.qc_geo,
result.qc_house,
address[:60],
)
return None
logger.info(
"dadata: enriched %r → canonical=%r cadnum=%s qc_geo=%s qc_house=%s",
address[:60],
(result.canonical_address or "")[:60],
result.house_cadnum,
result.qc_geo,
result.qc_house,
)
return result
# ── Suggest (autocomplete) ───────────────────────────────────────────────────
@dataclass(frozen=True, slots=True)
class DadataSuggestion:
"""Один candidate из ответа DaData /suggest/address.
value — короткая строка для отображения («ул Малышева, д 30»).
unrestricted_value — полная строка с регионом/страной («г Екатеринбург, ул …»).
lat / lon — координаты (если DaData их вернул; для some сегментов None).
fias_id — UUID ФИАС матчнутого объекта (дома/улицы/города).
house — номер дома если distinguished (строкой: «30» / «30к2»).
street — название улицы.
city — locality.
kind — `house` (fias_level=8) / `street` (=7) / `city` (<=6).
Маппится на GeocodeSuggestion.kind в geocoder.py.
"""
value: str
unrestricted_value: str
lat: float | None
lon: float | None
fias_id: str | None
house: str | None
street: str | None
city: str | None
kind: str
def _classify_kind(fias_level: Any) -> str:
"""Маппит DaData `fias_level` → kind для GeocodeSuggestion.
DaData fias_level (https://dadata.ru/api/suggest/address/#fias_level):
0=country, 1=region, 3=area, 4=city, 5=settlement, 6=street_alt,
7=street, 8=house, 9=flat, 65=planning, 75=plot.
Для autocomplete нам важно: house / street / city.
"""
level = _coerce_int(fias_level)
if level is None:
return "city" # safe default
if level >= 8:
return "house"
if level == 7:
return "street"
return "city"
def _parse_suggestion(item: dict[str, Any]) -> DadataSuggestion | None:
"""Парсит один элемент `suggestions[]` → DadataSuggestion.
Возвращает None если shape невалидный (нет value / нет data).
"""
if not isinstance(item, dict):
return None
value = item.get("value")
if not value or not isinstance(value, str):
return None
data = item.get("data") or {}
if not isinstance(data, dict):
data = {}
return DadataSuggestion(
value=value,
unrestricted_value=str(item.get("unrestricted_value") or value),
lat=_coerce_float(data.get("geo_lat")),
lon=_coerce_float(data.get("geo_lon")),
fias_id=data.get("fias_id"),
house=data.get("house"),
street=data.get("street"),
city=data.get("city"),
kind=_classify_kind(data.get("fias_level")),
)
async def suggest_addresses(
query: str, limit: int = 8, city: str = "Екатеринбург"
) -> list[DadataSuggestion]:
"""Автокомплит адресов через DaData /suggest/address.
Args:
query: текст для подсказки (минимум 2 символа).
limit: сколько вариантов вернуть (DaData параметр `count`, max 20).
city: locality для constraint (передаётся в `locations`). По умолчанию ЕКБ.
Returns:
list[DadataSuggestion] — пустой список если:
- DADATA_API_TOKEN не задан (graceful disable)
- query короче 2 символов
- сетевая / HTTP ошибка / 429 quota / malformed response
Token-only auth (X-Secret для suggest не нужен — отличается от clean_address).
Quota: 10000/день на free tier (в 100x больше чем у clean_address).
"""
if not query or len(query.strip()) < 2:
return []
token = (settings.dadata_api_token or "").strip()
if not token:
logger.debug("dadata suggest: DADATA_API_TOKEN не задан — skip")
return []
headers = {
"Authorization": f"Token {token}",
"Content-Type": "application/json",
"Accept": "application/json",
}
body: dict[str, Any] = {
"query": query.strip(),
"count": max(1, min(int(limit), 20)),
}
if city:
body["locations"] = [{"city": city}]
try:
async with httpx.AsyncClient(timeout=_DADATA_SUGGEST_TIMEOUT_S) as client:
response = await client.post(DADATA_SUGGEST_URL, headers=headers, json=body)
except (httpx.TimeoutException, httpx.NetworkError) as exc:
logger.warning("dadata suggest: network error для %r: %s", query[:60], exc)
return []
except Exception as exc: # pragma: no cover — defensive
logger.warning("dadata suggest: unexpected client error для %r: %s", query[:60], exc)
return []
status = response.status_code
if status == 429:
logger.warning("dadata suggest: HTTP 429 — quota exceeded (10k/день free?)")
return []
if status in (401, 403):
logger.error(
"dadata suggest: HTTP %d — auth rejected. Проверь DADATA_API_TOKEN.", status
)
return []
if status >= 500:
logger.warning("dadata suggest: HTTP %d — transient server error", status)
return []
if status >= 400:
body_preview = (response.text or "")[:200]
logger.warning("dadata suggest: HTTP %d — bad request: %r", status, body_preview)
return []
try:
payload = response.json()
except ValueError as exc:
logger.warning("dadata suggest: невалидный JSON в ответе: %s", exc)
return []
if not isinstance(payload, dict):
logger.warning("dadata suggest: response не dict (%s)", type(payload).__name__)
return []
raw_suggestions = payload.get("suggestions")
if not isinstance(raw_suggestions, list):
logger.info("dadata suggest: нет ключа `suggestions` в ответе для %r", query[:60])
return []
out: list[DadataSuggestion] = []
for item in raw_suggestions:
parsed = _parse_suggestion(item)
if parsed is not None:
out.append(parsed)
logger.info("dadata suggest: %r%d вариантов", query[:60], len(out))
return out