From 27fc8101e5f79935ff385635ee50de89b5161f94 Mon Sep 17 00:00:00 2001 From: Light1YT Date: Wed, 27 May 2026 19:48:37 +0500 Subject: [PATCH] =?UTF-8?q?fix(tradein):=20DaData=20backfill=20=E2=80=94?= =?UTF-8?q?=20=D0=95=D0=BA=D0=B0=D1=82=D0=B5=D1=80=D0=B8=D0=BD=D0=B1=D1=83?= =?UTF-8?q?=D1=80=D0=B3=20city=20context=20(#606=20hotfix)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../backend/scripts/backfill_houses_dadata.py | 46 +++++++++- .../scripts/test_backfill_houses_dadata.py | 83 +++++++++++++++++++ 2 files changed, 127 insertions(+), 2 deletions(-) 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 # ---------------------------------------------------------------------------