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
Owner

Why

Dry-run на проде после merge'а #606 показал критичный баг: DaData без явного города мапит короткие street-адреса в Москву по умолчанию. Пример из реального запуска:

улица Данилы Зверева   → г Москва, Бекасово р-н, д Зверево  (qc_geo=2)
бульвар Белоглазова    → г Москва, ул Владимира Этуша     (qc_geo=4)
Просторная улица       → г Москва, ул Просторная          (qc_geo=2)
Успенский проспект     → г Москва, пер Успенский           (qc_geo=2)

Если бы запустили без --dry-run и с lucky qc_geo=0/1 — UPDATE'ы бы перезаписали NULL coords на московские координаты. Catastrophic false-positive enrichment.

Исходный скрипт защищён двумя проверками:

1. _with_city_context(address) — pre-prepend

_EKB_CITY_PREFIX = "Екатеринбург, "
def _with_city_context(addr: str) -> str:
    if _EKB_CITY_MARKER in addr.lower():
        return addr  # уже есть город
    return _EKB_CITY_PREFIX + addr.strip()

Идемпотентен, case-insensitive. Гарантирует, что DaData всегда получает city-контекст.

2. _result_in_ekb(result) — post-filter

def _result_in_ekb(result) -> bool:
    city = result.raw.get('city') or ''
    if 'екатеринбург' in city.lower():
        return True
    return 'екатеринбург' in (result.canonical_address or '').lower()

Защита 2-го уровня: даже с префиксом DaData может проигнорировать city для тёзок. Проверяем raw['city'] (структурное поле) с fallback на substring в canonical_address.

3. _is_enriched_result() теперь требует EKB

Accept condition: qc_geo IN (0,1) AND coords AND city == Екатеринбург. Только тогда делаем UPDATE coords/cadnum/fias. Остальное — tracking-only (записываем dadata_enriched_at + qc-коды, но не overwrite payload).

Changes

File LOC
scripts/backfill_houses_dadata.py +44 -2
tests/scripts/test_backfill_houses_dadata.py +83

Tests

29 passed (20 original + 9 new)

Новые тесты:

  • _with_city_context — prepend / idempotent / case-insensitive / empty
  • _result_in_ekb — canonical fallback / raw.city / Moscow reject / None
  • _is_enriched_result_qc_geo_ok_but_non_ekb_city_rejects — integration of both layers

Ruff clean. Поле raw в фикстуре DadataAddressResult теперь корректно покрывает обе ветки post-filter'а.

Verification

После merge re-run dry-run на проде с 5 candidates → ожидаемо все 5 запросов будут с префиксом «Екатеринбург, ». Только тогда первый реальный (non-dry) запуск.

Next steps

  1. Merge → дождаться deploy
  2. docker exec tradein-backend python -m scripts.backfill_houses_dadata --limit 5 --priority coords --dry-run — проверить что префикс работает
  3. Real first batch --limit 50 --priority coords (≤50/100 дневной квоты)
  4. Verify записанные coords в houses
  5. Поставить на cron 100/день
## Why Dry-run на проде после merge'а #606 показал **критичный баг**: DaData без явного города мапит короткие street-адреса в Москву по умолчанию. Пример из реального запуска: ``` улица Данилы Зверева → г Москва, Бекасово р-н, д Зверево (qc_geo=2) бульвар Белоглазова → г Москва, ул Владимира Этуша (qc_geo=4) Просторная улица → г Москва, ул Просторная (qc_geo=2) Успенский проспект → г Москва, пер Успенский (qc_geo=2) ``` Если бы запустили без `--dry-run` и с lucky qc_geo=0/1 — UPDATE'ы бы перезаписали NULL coords на московские координаты. **Catastrophic false-positive enrichment**. Исходный скрипт защищён двумя проверками: ### 1. `_with_city_context(address)` — pre-prepend ```python _EKB_CITY_PREFIX = "Екатеринбург, " def _with_city_context(addr: str) -> str: if _EKB_CITY_MARKER in addr.lower(): return addr # уже есть город return _EKB_CITY_PREFIX + addr.strip() ``` Идемпотентен, case-insensitive. Гарантирует, что DaData всегда получает city-контекст. ### 2. `_result_in_ekb(result)` — post-filter ```python def _result_in_ekb(result) -> bool: city = result.raw.get('city') or '' if 'екатеринбург' in city.lower(): return True return 'екатеринбург' in (result.canonical_address or '').lower() ``` Защита 2-го уровня: даже с префиксом DaData может проигнорировать city для тёзок. Проверяем `raw['city']` (структурное поле) с fallback на substring в `canonical_address`. ### 3. `_is_enriched_result()` теперь требует EKB Accept condition: `qc_geo IN (0,1) AND coords AND city == Екатеринбург`. Только тогда делаем UPDATE coords/cadnum/fias. Остальное — tracking-only (записываем dadata_enriched_at + qc-коды, но не overwrite payload). ## Changes | File | LOC | |---|---| | `scripts/backfill_houses_dadata.py` | +44 -2 | | `tests/scripts/test_backfill_houses_dadata.py` | +83 | ## Tests ``` 29 passed (20 original + 9 new) ``` Новые тесты: - `_with_city_context` — prepend / idempotent / case-insensitive / empty - `_result_in_ekb` — canonical fallback / raw.city / Moscow reject / None - `_is_enriched_result_qc_geo_ok_but_non_ekb_city_rejects` — integration of both layers Ruff clean. Поле `raw` в фикстуре `DadataAddressResult` теперь корректно покрывает обе ветки post-filter'а. ## Verification После merge re-run dry-run на проде с 5 candidates → ожидаемо все 5 запросов будут с префиксом «Екатеринбург, ». Только тогда первый реальный (non-dry) запуск. ## Next steps 1. Merge → дождаться deploy 2. `docker exec tradein-backend python -m scripts.backfill_houses_dadata --limit 5 --priority coords --dry-run` — проверить что префикс работает 3. Real first batch `--limit 50 --priority coords` (≤50/100 дневной квоты) 4. Verify записанные coords в houses 5. Поставить на cron 100/день
Light1YT added 1 commit 2026-05-27 14:49:09 +00:00
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.
Light1YT merged commit 0380fe9e34 into main 2026-05-27 14:49:11 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: lekss361/gendesign#607
No description provided.