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
На проде у 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 восстановлен.
481 lines
20 KiB
Python
481 lines
20 KiB
Python
"""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
|