"""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