gendesign/tradein-mvp/backend/app/services/dadata.py
bot-backend e250869c9d
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI / changes (pull_request) Successful in 9s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 56s
fix(tradein/dadata): suggest-fallback для house_fias_id когда CLEAN выключен
На проде у DaData-аккаунта выключена услуга CLEAN (Стандартизация) → /clean/address
отдаёт HTTP 403 «Feature CLEAN disabled», из-за чего house_fias_id (ключ join с
houses/ДОМ.РФ) и гео терялись, хотя /suggest их отдаёт бесплатно (token-only).

- enrich_address(): /clean → graceful fallback на /suggest (house-level кандидат);
  canonical_address/house_cadnum честно None (их даёт только CLEAN — не выдумываем).
- clean_address(): 403 «feature disabled» логируется отдельно от auth-rejection
  (токен валиден — refresh не поможет; чинить = включить услугу либо fallback).
- DadataSuggestion + _parse_suggestion: тянут house_fias_id/kladr_id/qc_geo.
- estimator: enrichment-вызов → enrich_address (алиас dadata_clean_address сохранён,
  это точка патча в 11 тестах — без churn).
- Тесты: feature-disabled лог, enrich clean-path/suggest-fallback/both-fail, новые поля.
  Проверено вживую на прод-кредах: clean→403, enrich→house_fias_id восстановлен.
2026-07-12 19:55:37 +03:00

481 lines
20 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):
body_preview = (response.text or "")[:200]
# 403 «Feature 'CLEAN' disabled for token …» ≠ отклонённый токен: токен валиден,
# но услуга «Стандартизация» (CLEAN) не подключена на аккаунте. Refresh токена НЕ
# поможет — нужно включить услугу в кабинете DaData ИЛИ полагаться на suggest-fallback
# (enrich_address). Разделяем сообщения, чтобы не гонять зря за ротацией токена.
if status == 403 and (
"disabled" in body_preview.lower() or "feature" in body_preview.lower()
):
logger.error(
"dadata: HTTP 403 — услуга CLEAN (Стандартизация) выключена на аккаунте "
"(токен валиден, НЕ отклонён). Включи услугу в кабинете DaData или "
"полагайся на suggest-fallback (enrich_address). Ответ: %r",
body_preview,
)
else:
logger.error(
"dadata: HTTP %d — auth/secret rejected. "
"Проверь DADATA_API_TOKEN/SECRET. Ответ: %r",
status,
body_preview,
)
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/9) / `street` (=7) / `city` (<=6) /
`plot` (=65 planning, =75 plot).
Маппится на 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
# #dadata-403 — доп. поля для suggest-fallback в enrich_address (когда CLEAN
# выключен на аккаунте). house_fias_id — ФИАС ДОМА (для house-level == fias_id);
# kladr_id / qc_geo дублируют то, что отдаёт /clean, чтобы деградация была равноценной.
house_fias_id: str | None = None
kladr_id: str | None = None
qc_geo: int | None = None
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 in (65, 75):
# 65=planning structure / 75=земельный участок — не дом.
# geocoder._DADATA_KIND_MAP не знает 'plot' → fallback на 'locality'.
return "plot"
if level in (8, 9):
# 8=house, 9=flat — оба привязаны к конкретному дому.
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")),
house_fias_id=data.get("house_fias_id"),
kladr_id=data.get("kladr_id"),
qc_geo=_coerce_int(data.get("qc_geo")),
)
async def suggest_addresses(
query: str,
limit: int = 8,
city: str | None = "Екатеринбург",
region: str | None = None,
) -> list[DadataSuggestion]:
"""Автокомплит адресов через DaData /suggest/address.
Args:
query: текст для подсказки (минимум 2 символа).
limit: сколько вариантов вернуть (DaData параметр `count`, max 20).
city: locality для constraint (передаётся в `locations`). По умолчанию ЕКБ.
Игнорируется, если задан `region`.
region: область для hard-constraint в `locations` — используется вместо
`city`, когда нужен охват шире одного города (например, вся
Свердловская область / region 66). DaData `locations` сам по себе уже
жёсткий фильтр (не boost) на уровне указанного admin-поля — доп.
параметров не требуется. По умолчанию не задан — поведение (и body
запроса) для существующих вызовов не меняется.
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 region:
# `locations` с полем region — уже жёсткий фильтр сам по себе (DaData
# ограничивает выдачу этим регионом). `restrict_value` — ТОП-LEVEL параметр
# body (не ключ внутри объекта locations) — здесь он был бы silent no-op,
# поэтому не добавляем.
body["locations"] = [{"region": region}]
elif 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
# ── Enrichment orchestrator (clean → suggest fallback) ───────────────────────
async def enrich_address(address: str) -> DadataAddressResult | None:
"""Обогащение целевого адреса с graceful-деградацией CLEAN → SUGGEST.
Порядок:
1. /clean/address (token+secret) — canonical + house_cadnum + house_fias_id +
qc_geo + metro. Если вернул результат — используем его.
2. Fallback: /suggest/address (token-only, доступен на free-tier 10k/день) —
берём top house-level кандидата. Даёт house_fias_id + гео + kladr_id + qc_geo,
но НЕ canonical_address / house_cadnum (их умеет только CLEAN) → они остаются
None. Честная деградация: недостающие поля не выдумываем.
Мотивация (#dadata-403, vault Mera_Hard_Audit_0712): на проде у аккаунта услуга
CLEAN выключена (HTTP 403 «Feature CLEAN disabled»), из-за чего house_fias_id —
ключ join с houses/ДОМ.РФ (year_built и пр.) — терялся, хотя /suggest его отдаёт
бесплатно. Город НЕ ограничиваем (city="") — enrichment работает по всей обл. (66).
"""
cleaned = await clean_address(address)
if cleaned is not None:
return cleaned
suggestions = await suggest_addresses(address, limit=5, city="")
for s in suggestions:
fias = s.house_fias_id or s.fias_id
if s.kind == "house" and fias:
# house-level ФИАС → гео дома точное; qc_geo из ответа либо 0 (exact) по умолчанию.
qc_geo = s.qc_geo if s.qc_geo is not None else 0
logger.info(
"dadata: CLEAN недоступен → suggest-fallback %r → house_fias_id=%s "
"(canonical/cadnum недоступны без CLEAN)",
address[:60],
fias,
)
return DadataAddressResult(
canonical_address=None,
house_cadnum=None,
house_fias_id=fias,
lat=s.lat,
lon=s.lon,
qc_geo=qc_geo,
qc_house=None,
kladr_id=s.kladr_id,
okato=None,
oktmo=None,
metro=[],
raw={"_source": "suggest_fallback", "value": s.value},
)
return None