gendesign/tradein-mvp/backend/app/services/dadata.py
Light1YT a7fa1a4ffa feat(tradein): DaData on-demand enrichment в estimate flow (PR Q1)
Добавляет app/services/dadata.py — async client для DaData /clean/address,
обогащающий target адрес canonical-формой, kadastr_num, ФИАС, координатами
и ближайшим метро. Migration 069 расширяет trade_in_estimates 6 nullable
колонками; estimator.estimate_quality вызывает service после geocode и
сохраняет результат в основной INSERT. AggregatedEstimate отдаёт
canonical_address / house_cadnum / house_fias_id / metro_nearest наружу.

Service graceful: пустые credentials / quota 429 / 5xx / network fail → None,
estimator продолжает работу. Demo tier 100 req/день — хватит для low-traffic
prod и тестов. ENV: DADATA_API_TOKEN, DADATA_API_SECRET.

Tests: 20 unit-тестов через httpx.MockTransport, zero real DaData calls.
Покрыто: happy path, отсутствие creds, короткий адрес, timeout/network,
HTTP 429/401/403/500/502/503, пустой массив, malformed shape, partial
payload, coerce строковых qc-кодов в int, проверка request body+headers.

Закладывает базу для PR Q2 (frontend DaData Suggest, 10k/день free) и
PR Q3 (backfill 339 NULL-coords houses) — обе используют тот же сервис.
2026-05-27 18:50:12 +05:00

214 lines
7.7 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 /clean/address client — enrich address с canonical form,
kadastr_num, ФИАС, координаты, метро.
Используется в estimator flow для on-demand обогащения **целевого адреса**
(payload.address) — заполняет недостающий kadastr_num, который open dataset
Росреестра не даёт (см. PR Q1 / vault `meta/00_credentials.md`).
ENV: DADATA_API_TOKEN, DADATA_API_SECRET.
Если не задан хотя бы один — service возвращает None gracefully (не break flow).
Rate limit: 100/день demo tier. Для production надо upgrade'нуть.
Endpoint: POST https://cleaner.dadata.ru/api/v1/clean/address
Headers: Authorization: Token <T>, X-Secret: <S>, Content-Type: application/json
Body: ["<address text>"]
Response: list[dict] — обычно один элемент с полями result/house_cadnum/geo_lat/...
Docs: https://dadata.ru/api/clean/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_TIMEOUT_S = 8.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