fix(tradein): DaData backfill — Екатеринбург city context (#606 hotfix)
Dry-run на проде показал, что DaData без явного города мапит короткие street-адреса в Москву по умолчанию («улица Просторная» → «г Москва, ул Просторная»). Это бы привело к catastrophic false-positive enrichment'у если бы скрипт запустили без --dry-run. Two-layer fix: 1. `_with_city_context(addr)` — pre-pends «Екатеринбург, » если в адресе ещё нет упоминания города. Идемпотентен, case-insensitive. 2. `_result_in_ekb(result)` — post-filter. Проверяет `raw.city` (структурное поле DaData) с fallback на substring search в `canonical_address`. Защищает даже если DaData проигнорирует city-префикс и всё равно вернёт другой город (например для homonym streets). `_is_enriched_result()` теперь требует обе проверки: qc_geo IN (0,1) AND coords AND city == Екатеринбург. Только так делаем UPDATE coords/cadnum. Без этого fix'а нельзя запускать backfill — все non-EKB false-positives перезаписали бы NULL coords мусором. 9 новых тестов: city context (prepend / idempotent / case / empty), post-filter (raw.city / canonical / Moscow reject / None). Combined 29 passed.
This commit is contained in:
parent
08feb60a7f
commit
27fc8101e5
2 changed files with 127 additions and 2 deletions
|
|
@ -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:
|
||||
"""Решает, можно ли 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».
|
||||
"""
|
||||
if result is None:
|
||||
|
|
@ -301,6 +339,8 @@ def _is_enriched_result(result: DadataAddressResult | None) -> bool:
|
|||
return False
|
||||
if result.lat is None or result.lon is None:
|
||||
return False
|
||||
if not _result_in_ekb(result):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
|
|
@ -325,7 +365,9 @@ async def _run_backfill(
|
|||
try:
|
||||
# `clean_address` сам обрабатывает HTTP errors / quota / timeout
|
||||
# и возвращает 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
|
||||
stats.failed += 1
|
||||
stats.bump(priority, "failed")
|
||||
|
|
|
|||
|
|
@ -31,10 +31,12 @@ from scripts.backfill_houses_dadata import (
|
|||
HouseRow,
|
||||
Stats,
|
||||
_is_enriched_result,
|
||||
_result_in_ekb,
|
||||
_run_backfill,
|
||||
_select_candidates,
|
||||
_update_house_attempt,
|
||||
_update_house_enriched,
|
||||
_with_city_context,
|
||||
main,
|
||||
)
|
||||
|
||||
|
|
@ -146,6 +148,87 @@ def test_is_enriched_result_qc_geo_ok_but_no_coords_rejects():
|
|||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue