diff --git a/tradein-mvp/backend/scripts/backfill_houses_dadata.py b/tradein-mvp/backend/scripts/backfill_houses_dadata.py index 65e144da..c3fe8038 100644 --- a/tradein-mvp/backend/scripts/backfill_houses_dadata.py +++ b/tradein-mvp/backend/scripts/backfill_houses_dadata.py @@ -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") diff --git a/tradein-mvp/backend/tests/scripts/test_backfill_houses_dadata.py b/tradein-mvp/backend/tests/scripts/test_backfill_houses_dadata.py index 661f1e38..87aebe46 100644 --- a/tradein-mvp/backend/tests/scripts/test_backfill_houses_dadata.py +++ b/tradein-mvp/backend/tests/scripts/test_backfill_houses_dadata.py @@ -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 # ---------------------------------------------------------------------------