gendesign/tradein-mvp/backend/app/services/dadata.py
bot-backend c0cbdc2f99
All checks were successful
CI / changes (pull_request) Successful in 7s
CI / backend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 50s
CI Trade-In / changes (pull_request) Successful in 8s
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
fix(tradein/geocoder): раз-хардкод геокодера/DaData на всю Свердловскую обл. (region 66)
Геокодер был жёстко EKB-bound и ВЫБРАСЫВАЛ корректные non-EKB геокоды (bbox-accept-фильтры) → блокировал оценку любого адреса области. Обобщено до region-66.

- OBLAST66_BBOX + is_within_oblast66_bbox (superset EKB-tight, EKB не затронут); EKB Yandex Tier1/2 fast-path сохранён.
- Все accept-фильтры (Yandex/Nominatim forward+suggest, geocode() accept+typo) → oblast-66.
- City-prefix по word-boundary _has_oblast_marker (не substring): EKB-улицы 'Серова 27'/'Ирбитская 5' и др. больше не роняют 'Екатеринбург,'-префикс (был silent wrong-city баг).
- Two-pass tie-break в accept-циклах: tight-EKB кандидат приоритетнее → EKB-результат идентичен прежнему.
- Region cross-check (Nominatim address.state / Yandex AdministrativeAreaName) отсекает соседние области (Тюмень и др.); bbox-fallback когда region отсутствует.
- DaData suggest: region-параметр (hard-filter), убран no-op restrict_value.
- +26 тестов: OBLAST66 superset TIGHT, far-town accept, Тюмень reject, marker +/- (street-collision), DaData region body.

Foundation для A2 (геокодинг non-EKB сделок) и live-оценки адресов области.
2026-07-12 18:33:05 +03:00

403 lines
16 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/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
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")),
)
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