gendesign/tradein-mvp/backend/app/services/dadata.py
bot-backend d63702cfec
All checks were successful
CI / changes (pull_request) Successful in 8s
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 2m44s
fix(tradein/geocoder): литера дома — часть идентичности, а не tie-break
`_cadastral_house_match` сравнивал дом только по ЦИФРАМ: литера была
опциональна в WHERE (`[а-яё]?`) и участвовала лишь как tie-break в
ORDER BY. Баг двусторонний и молчаливый:

  «Новгородцевой 13б» → «дом 13»   (дома 13б в реестре нет вообще)
  «Малышева 30»       → «д. 30-б»  (обратное направление, живой прод-кейс)

Оба результата возвращались с `confidence="exact"` и оседали в
`geocode_cache` на 90 дней — пользователь получал оценку ЧУЖОГО здания,
помеченную как точная.

Почему так: номер дома теперь сравнивается РАВЕНСТВОМ нормализованных
форм. Обе стороны приводятся к одному канону (`13б` / `13 б` / `13-б` /
`13Б` → `13б`): запрос — существующим `_norm_house`, реестр — тем же
выражением на стороне Postgres. Дешёвый regex-anchor по цифрам оставлен
prefilter'ом (пушится в FDW, режет «(1-83)»-диапазоны), но корректность
теперь на равенстве, а не на нём.

Побочно закрыты той же логикой:
  • «58» больше не матчит «58/3», «13» не матчит «130» — угловой номер
    это часть номера дома, а не мусор;
  • маркер дома ищется только с начала слова (`\m`), иначе «проезд 8
    Марта, д 5» давал дом «8» — старый `д\.?` ловил «д» внутри «проезд»
    (20 таких строк реестра были достижимы ТОЛЬКО через этот баг);
  • номер дома больше не конкатенируется в regex — regex-injection
    поверхность сузилась до цифр prefilter'а.

Промаха не превращаем в тихую подстановку соседа: нет дома с нужной
литерой → None, дальше отрабатывают следующие тиры (forward → DaData →
Nominatim). Понижать confidence здесь нечем — `GeocodeSuggestion` его не
несёт, все локальные тиры хардкодят "exact"; честный фолбэк дешевле, чем
протаскивать новый канал уверенности через три тира.

DaData-тир, который должен подхватывать такие адреса, был мёртв: в
`locations` уходило `region="Свердловская область"`, тогда как DaData
хранит имя региона БЕЗ типа (`region="Свердловская"`, `region_type="обл"`).
Hard-filter не совпадал ни с чем и молча схлопывал выдачу в 0 подсказок
(замер на проде: 0 хитов против 5 с «Свердловская», первый — искомый
«д 13б» с fias_id). Добавлен warning на пустую выдачу под region-
констрейнтом, чтобы следующая такая регрессия не была невидимой.

Миграция 203 инвалидирует уже отравленные записи `geocode_cache` —
точечно (запрос с литерой, либо запрос без литеры с закэшированным
домом С литерой), а не весь кэш: полная очистка сожгла бы квоту DaData
на ре-резолв заведомо корректных адресов.

Проверено: SQL-выражение прогнано против живого реестра (47k строк) —
«13б»→∅, «13»→дом 13, «30»→сооружение 30 (не «30-б»), «23б»→«д. 23-б»;
предикат миграции — против таблицы кейсов (4 delete / 8 keep).
Полный pytest: 1 failed, 3123 passed — падение
`test_search_api.py::test_search_cache_hit` (401) воспроизводится и на
чистом main (baseline 1 failed, 3081 passed), к этому изменению
отношения не имеет.

НЕ проверено вживую: ответ DaData с новым region-констрейнтом (нет
доступа к прод-токену из этой сессии) — опираюсь на замер из отчёта об
issue и на модель данных DaData.
2026-08-02 13:30:16 +03:00

485 lines
21 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
запроса) для существующих вызовов не меняется.
ВАЖНО: значение сравнивается с полем DaData `region`, где имя лежит
БЕЗ типа («Свердловская», а тип — отдельно в `region_type`="обл").
Передашь «Свердловская область» — совпадений не будет, и запрос
вернёт ПУСТО без всякой ошибки (hard-filter, не boost).
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