fix(tradein): DaData backfill — Екатеринбург city context (#606 hotfix) #607

Merged
Light1YT merged 1 commit from fix/tradein-dadata-backfill-ekb-context into main 2026-05-27 14:49:11 +00:00
2 changed files with 127 additions and 2 deletions

View file

@ -289,10 +289,48 @@ def _update_house_attempt(
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# «Екатеринбург» в любом регистре — короткие адреса типа «улица Малышева, 125»
# без явного города DaData по умолчанию маппит в Москву → подсовываем префикс.
_EKB_CITY_PREFIX = "Екатеринбург, "
_EKB_CITY_MARKER = "екатеринбург"
def _with_city_context(address: str) -> str:
"""Pre-pend «Екатеринбург, » если в адресе ещё нет указания города.
DaData /clean без city-контекста выбирает Москву по умолчанию для
тёзок (улица Просторная ул Просторная в Москве). Этот wrapper гарантирует,
что для коротких street-адресов из houses.address DaData получает явный город.
Идемпотентен: «Екатеринбург, улица Малышева, 125» не префиксируется повторно.
"""
if not address:
return address
if _EKB_CITY_MARKER in address.lower():
return address
return _EKB_CITY_PREFIX + address.strip()
def _result_in_ekb(result: DadataAddressResult | None) -> bool:
"""Safety: True если DaData вернул действительно Екатеринбургский адрес.
Защищает от false-positives когда DaData мапит улицу-тёзку (даже после
префикса) в другой город. Использует `result.raw.get('city')` (структурное
поле) c fallback на substring search в `canonical_address`.
"""
if result is None:
return False
city = (result.raw.get("city") or "") if isinstance(result.raw, dict) else ""
if _EKB_CITY_MARKER in city.lower():
return True
canonical = (result.canonical_address or "").lower()
return _EKB_CITY_MARKER in canonical
def _is_enriched_result(result: DadataAddressResult | None) -> bool: def _is_enriched_result(result: DadataAddressResult | None) -> bool:
"""Решает, можно ли overwrite'ить координаты + cadnum по этому result'у. """Решает, можно ли overwrite'ить координаты + cadnum по этому result'у.
Условие: qc_geo IN (0, 1) AND lat / lon заданы. Условие: qc_geo IN (0, 1) AND lat / lon заданы AND city == Екатеринбург.
Возврат False означает «записать только tracking, не overwrite payload». Возврат False означает «записать только tracking, не overwrite payload».
""" """
if result is None: if result is None:
@ -301,6 +339,8 @@ def _is_enriched_result(result: DadataAddressResult | None) -> bool:
return False return False
if result.lat is None or result.lon is None: if result.lat is None or result.lon is None:
return False return False
if not _result_in_ekb(result):
return False
return True return True
@ -325,7 +365,9 @@ async def _run_backfill(
try: try:
# `clean_address` сам обрабатывает HTTP errors / quota / timeout # `clean_address` сам обрабатывает HTTP errors / quota / timeout
# и возвращает None — exception будет только при unexpected error. # и возвращает None — exception будет только при unexpected error.
result = await clean_address(row.address) # `_with_city_context` префиксирует «Екатеринбург, » если в адресе
# ещё нет указания города (защита от Москва-by-default mapping).
result = await clean_address(_with_city_context(row.address))
except Exception as exc: # defensive — одна ошибка не должна валить batch except Exception as exc: # defensive — одна ошибка не должна валить batch
stats.failed += 1 stats.failed += 1
stats.bump(priority, "failed") stats.bump(priority, "failed")

View file

@ -31,10 +31,12 @@ from scripts.backfill_houses_dadata import (
HouseRow, HouseRow,
Stats, Stats,
_is_enriched_result, _is_enriched_result,
_result_in_ekb,
_run_backfill, _run_backfill,
_select_candidates, _select_candidates,
_update_house_attempt, _update_house_attempt,
_update_house_enriched, _update_house_enriched,
_with_city_context,
main, main,
) )
@ -146,6 +148,87 @@ def test_is_enriched_result_qc_geo_ok_but_no_coords_rejects():
assert _is_enriched_result(res) is False assert _is_enriched_result(res) is False
def test_is_enriched_result_qc_geo_ok_but_non_ekb_city_rejects():
"""Safety: qc=0 + coords но город НЕ Екатеринбург → не overwrite.
DaData без явного города мапит тёзки в Москву (баг dry-run 2026-05-27).
Защищаемся post-filter'ом по `canonical_address`/`raw.city`.
"""
res = _make_dadata_result(
qc_geo=0,
lat=55.755,
lon=37.617,
canonical="г Москва, ул Просторная, д 1",
)
assert _is_enriched_result(res) is False
# ---------------------------------------------------------------------------
# _with_city_context — pre-pending «Екатеринбург, »
# ---------------------------------------------------------------------------
def test_with_city_context_prepends_when_missing():
assert _with_city_context("улица Малышева, 125") == "Екатеринбург, улица Малышева, 125"
def test_with_city_context_is_idempotent_when_city_present():
"""Уже-полный адрес не префиксируется повторно."""
full = "Екатеринбург, улица Малышева, 125"
assert _with_city_context(full) == full
def test_with_city_context_case_insensitive():
"""ЕКАТЕРИНБУРГ / екатеринбург — тоже не префиксируем."""
assert _with_city_context("ЕКАТЕРИНБУРГ, ул. Ленина") == "ЕКАТЕРИНБУРГ, ул. Ленина"
assert _with_city_context("екатеринбург, ленина 1") == "екатеринбург, ленина 1"
def test_with_city_context_empty_passthrough():
"""Пустой / None не падает."""
assert _with_city_context("") == ""
# ---------------------------------------------------------------------------
# _result_in_ekb — городской post-filter
# ---------------------------------------------------------------------------
def test_result_in_ekb_uses_canonical_address_fallback():
"""Если raw.city пустой — fallback на substring в canonical_address."""
res = _make_dadata_result(canonical="г Екатеринбург, ул Малышева, д 125")
assert _result_in_ekb(res) is True
def test_result_in_ekb_uses_raw_city_field():
"""raw['city']='Екатеринбург' → True (даже если canonical другое)."""
res = _make_dadata_result(canonical="ул Малышева, д 125")
res = DadataAddressResult(
canonical_address=res.canonical_address,
house_cadnum=res.house_cadnum,
house_fias_id=res.house_fias_id,
lat=res.lat,
lon=res.lon,
qc_geo=res.qc_geo,
qc_house=res.qc_house,
kladr_id=None,
okato=None,
oktmo=None,
metro=[],
raw={"city": "Екатеринбург"},
)
assert _result_in_ekb(res) is True
def test_result_in_ekb_rejects_moscow():
res = _make_dadata_result(canonical="г Москва, ул Просторная, д 1")
assert _result_in_ekb(res) is False
def test_result_in_ekb_none_input():
assert _result_in_ekb(None) is False
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# _update_house_enriched — bind shape # _update_house_enriched — bind shape
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------