fix(tradein/geocoder): инвариант «чужой топоним + результат внутри ЕКБ = недостоверно» (#2590) #2837

Merged
bot-backend merged 1 commit from fix/2590-geocoder-city-invariant into main 2026-08-12 14:26:58 +00:00
Collaborator

Refs #2590. Закрывает последний канал подмены города — внешние провайдеры, которые гейты #2582/#2589 не покрывают ни до, ни после.

Замер на проде (до фикса)

geocode_cache, 9514 строк, 2026-08-12 — прогон шипаемым кодом, не прототипом:

provider всего подмен
yandex 5646 1245
nominatim 3226 6
cache (локальные ЕКБ-тиры) 642 0
итого 9514 1251 (13.1%)

365 разных населённых пунктов: Реж (36), Лесной (34), Талица (33), Асбестовский (28), Белоярский (28), Кировград (28), Волчанск (23)… Ноль у локальных тиров — не «чисто», а «нечем проверить»: геопортал отдаёт «Улица, дом» без НП (см. Потолки).

Последствие в цене

572 листинга получили координаты ИЗ отравленной записи кэша (совпадение нормализованного адреса + точка ≤50 м), из них 17 активны сегодня: Кедровка, Большое Седельниково, Сарапулка, Совхозный, Арамиль, Новоалексеевское, Курганово. Пять из них лежат ровно в точке 56.83744/60.59764 — это яндексовый центроид «Екатеринбург», то есть деревенские лоты стоят в центре города и попадают в екатеринбургский пул аналогов.

Экспозиция оценок (1053 оценки с координатами): у 164 отравленный активный листинг в радиусе 1 км, у 459 — в радиусе 2 км. Это верхняя граница «могли зацепить» (фактический отбор ещё фильтрует по комнатам/площади), а не «зацепили».

Как реализован инвариант — без списка

_claimed_locality(address) идёт по сегментам в порядке РФ-адреса (страна → регион → район → НП → улица → дом), пропускает уровни выше и ниже НП по структурным маркерам («обл.», «край», «р-н», «мкр»), останавливается на уровне улицы/дома. Первый оставшийся сегмент — имя НП; None = «город не назван» (основной трафик формы «Малышева 30»), инвариант не применяется вовсе.

_city_substituted(address, result) — три условия вместе:

  1. НП назван и это не Екатеринбург;
  2. его имени нет в адресе, который вернул провайдер;
  3. результат внутри ЕКБ — и по координатам (EKB_BBOX_TIGHT), и по собственному ответу провайдера (он называет Екатеринбург либо не называет НП вовсе).

Имён городов в коде нет — только уровни адреса и целевой город. Единственная константа-топоним помимо ЕКБ — «Россия» (страна, у неё не появляется новых значений, в отличие от списка городов области).

Почему постфактум, а не на входе. На входе «Реж» и «Малышева» структурно неразличимы — оба «слово до запятой»; закрыть по этому признаку гейт значило бы сломать основной сценарий ввода без города (ровно тот компромисс, что зафиксирован как известная дыра в #2589). После ответа они различимы: провайдер сам пишет, какой НП использовал.

Что с посёлками в городской черте

Разделяет их не география, а сохранность топонима. «пос. Кедровка, Советская ул., 5»:

  • ответ «5, Советская улица, Кедровка, Орджоникидзевский район, Екатеринбург» → имя дожило → проходит;
  • ответ «Екатеринбург, Советская улица, 5» @ 56.8506/60.6282 (живая запись прода) → имя потеряно → отбраковка, и это правда подмена: настоящая Кедровка в 20 км от той улицы.

То есть посёлок не выбрасывается за то, что он посёлок — граница из #2777 здесь не задевается. Оба случая закреплены тестами.

Что с испорченным кэшем — ничего не удаляем

Проверка стоит одной точкой на выходе geocode(), поэтому применяется и к попаданию в кэш: 1251 отравленная запись продолжает отдаваться, но уже не как точная. DELETE не нужен, откат кода полностью возвращает прежнее поведение. Точечная чистка (DELETE FROM geocode_cache WHERE address_normalized IN (…), список воспроизводим прогоном _city_substituted по таблице) остаётся отдельным решением — здесь сознательно не делается.

Последствие сработки

confidence="locality" + city_ambiguous=True + logger.warning. locality — не косметика, а существующий рабочий контракт: estimator._geocode_is_coarse включает #693 coarse-downgrade оценки, а tasks.geocode_missing ставит листингу geo_precision='city' — этот признак исключает листинг из пула аналогов (geo_precision IS DISTINCT FROM 'city' в estimator и location_index). Координаты не выбрасываются: деградация честная и видимая, а не отказ.

Потолки (осознанные)

  • Локальные ЕКБ-тиры не проверяются: геопортал отдаёт full_address = «Улица, дом» без НП — свидетельства нет, вердикта нет. Дыра #2589 («Реж, Ленина 5» с номером дома идёт в геопортал) этим PR не закрыта.
  • Имя НП, совпавшее с уличным токеном ответа, считается дожившим: «Ачит» ⊂ «М-12 Ачит-Екатеринбург», «Лесной» ⊂ «Лесной переулок». Пропуск, не ложная отбраковка.
  • Жилрайон ЕКБ, названный без приставки («пионерский, советская», «мичуринский, цветоносная») понижается до locality — 6 топонимов из 365. Цена ошибки асимметрична: ложная отбраковка = честное «не уверены в городе», пропуск = уверенно неверная цена.
  • Подмена на соседний город («режевской, ленина» → Берёзовский) под инвариант не подпадает: результат не внутри Екатеринбурга. Это формулировка самого #2590; обобщение — отдельная задача.

Test plan

  • tests/test_geocoder_city_substitution.py — 30 тестов, адреса и full_address живые из прод-кэша
  • Красный прогон на старом коде: откат только рабочего кода (тесты те же) → test_geocode_downgrades_substituted_result и test_geocode_poisoned_cache_hit_downgraded падают (assert 'exact' == 'locality'), при этом контроли — ЕКБ-адрес и честный ответ по Кедровке — проходят и на старом коде. Тест не тавтологичен: он падает на дефекте и молчит на норме
  • Все наборы геокодера вместе: 246 passed
  • ruff check / ruff format чисто, scripts/check-migration-lock-timeout.py✓ блокирующий DDL прикрыт lock_timeout (проверено новых миграций: 6)
  • Прод-верификация: критерий записан ДО факта (см. комментарий к #2590)
Refs #2590. Закрывает последний канал подмены города — внешние провайдеры, которые гейты #2582/#2589 не покрывают ни до, ни после. ## Замер на проде (до фикса) `geocode_cache`, 9514 строк, 2026-08-12 — прогон **шипаемым кодом**, не прототипом: | provider | всего | подмен | |---|---|---| | yandex | 5646 | **1245** | | nominatim | 3226 | **6** | | cache (локальные ЕКБ-тиры) | 642 | **0** | | **итого** | **9514** | **1251** (13.1%) | 365 разных населённых пунктов: Реж (36), Лесной (34), Талица (33), Асбестовский (28), Белоярский (28), Кировград (28), Волчанск (23)… Ноль у локальных тиров — не «чисто», а «нечем проверить»: геопортал отдаёт «Улица, дом» без НП (см. Потолки). ## Последствие в цене 572 листинга получили координаты ИЗ отравленной записи кэша (совпадение нормализованного адреса + точка ≤50 м), из них **17 активны сегодня**: Кедровка, Большое Седельниково, Сарапулка, Совхозный, Арамиль, Новоалексеевское, Курганово. Пять из них лежат ровно в точке 56.83744/60.59764 — это яндексовый центроид «Екатеринбург», то есть деревенские лоты стоят в центре города и попадают в екатеринбургский пул аналогов. Экспозиция оценок (1053 оценки с координатами): у **164** отравленный активный листинг в радиусе 1 км, у **459** — в радиусе 2 км. Это верхняя граница «могли зацепить» (фактический отбор ещё фильтрует по комнатам/площади), а не «зацепили». ## Как реализован инвариант — без списка `_claimed_locality(address)` идёт по сегментам в порядке РФ-адреса (страна → регион → район → **НП** → улица → дом), пропускает уровни выше и ниже НП по структурным маркерам («обл.», «край», «р-н», «мкр»), **останавливается** на уровне улицы/дома. Первый оставшийся сегмент — имя НП; `None` = «город не назван» (основной трафик формы «Малышева 30»), инвариант не применяется вовсе. `_city_substituted(address, result)` — три условия вместе: 1. НП назван и это не Екатеринбург; 2. его имени **нет** в адресе, который вернул провайдер; 3. результат внутри ЕКБ — и по координатам (`EKB_BBOX_TIGHT`), и по собственному ответу провайдера (он называет Екатеринбург либо не называет НП вовсе). Имён городов в коде нет — только уровни адреса и целевой город. Единственная константа-топоним помимо ЕКБ — «Россия» (страна, у неё не появляется новых значений, в отличие от списка городов области). **Почему постфактум, а не на входе.** На входе «Реж» и «Малышева» структурно неразличимы — оба «слово до запятой»; закрыть по этому признаку гейт значило бы сломать основной сценарий ввода без города (ровно тот компромисс, что зафиксирован как известная дыра в #2589). После ответа они различимы: провайдер сам пишет, какой НП использовал. ## Что с посёлками в городской черте Разделяет их не география, а сохранность топонима. «пос. Кедровка, Советская ул., 5»: - ответ «5, Советская улица, **Кедровка**, Орджоникидзевский район, Екатеринбург» → имя дожило → **проходит**; - ответ «Екатеринбург, Советская улица, 5» @ 56.8506/60.6282 (живая запись прода) → имя потеряно → **отбраковка**, и это правда подмена: настоящая Кедровка в 20 км от той улицы. То есть посёлок не выбрасывается за то, что он посёлок — граница из #2777 здесь не задевается. Оба случая закреплены тестами. ## Что с испорченным кэшем — ничего не удаляем Проверка стоит одной точкой на выходе `geocode()`, поэтому применяется и к попаданию в кэш: 1251 отравленная запись продолжает отдаваться, но уже **не как точная**. DELETE не нужен, откат кода полностью возвращает прежнее поведение. Точечная чистка (`DELETE FROM geocode_cache WHERE address_normalized IN (…)`, список воспроизводим прогоном `_city_substituted` по таблице) остаётся отдельным решением — здесь сознательно не делается. ## Последствие сработки `confidence="locality"` + `city_ambiguous=True` + `logger.warning`. `locality` — не косметика, а существующий рабочий контракт: `estimator._geocode_is_coarse` включает #693 coarse-downgrade оценки, а `tasks.geocode_missing` ставит листингу `geo_precision='city'` — этот признак исключает листинг из пула аналогов (`geo_precision IS DISTINCT FROM 'city'` в `estimator` и `location_index`). Координаты не выбрасываются: деградация честная и видимая, а не отказ. ## Потолки (осознанные) - **Локальные ЕКБ-тиры не проверяются**: геопортал отдаёт `full_address` = «Улица, дом» без НП — свидетельства нет, вердикта нет. Дыра #2589 («Реж, Ленина 5» с номером дома идёт в геопортал) этим PR не закрыта. - **Имя НП, совпавшее с уличным токеном ответа**, считается дожившим: «Ачит» ⊂ «М-12 Ачит-Екатеринбург», «Лесной» ⊂ «Лесной переулок». Пропуск, не ложная отбраковка. - **Жилрайон ЕКБ, названный без приставки** («пионерский, советская», «мичуринский, цветоносная») понижается до `locality` — 6 топонимов из 365. Цена ошибки асимметрична: ложная отбраковка = честное «не уверены в городе», пропуск = уверенно неверная цена. - **Подмена на соседний город** («режевской, ленина» → Берёзовский) под инвариант не подпадает: результат не внутри Екатеринбурга. Это формулировка самого #2590; обобщение — отдельная задача. ## Test plan - [x] `tests/test_geocoder_city_substitution.py` — 30 тестов, адреса и `full_address` живые из прод-кэша - [x] **Красный прогон на старом коде**: откат только рабочего кода (тесты те же) → `test_geocode_downgrades_substituted_result` и `test_geocode_poisoned_cache_hit_downgraded` падают (`assert 'exact' == 'locality'`), при этом контроли — ЕКБ-адрес и **честный ответ по Кедровке** — проходят и на старом коде. Тест не тавтологичен: он падает на дефекте и молчит на норме - [x] Все наборы геокодера вместе: 246 passed - [x] `ruff check` / `ruff format` чисто, `scripts/check-migration-lock-timeout.py` → `✓ блокирующий DDL прикрыт lock_timeout (проверено новых миграций: 6)` - [ ] Прод-верификация: критерий записан ДО факта (см. комментарий к #2590)
bot-backend added 1 commit 2026-08-12 14:22:01 +00:00
fix(tradein/geocoder): инвариант «чужой топоним + результат внутри ЕКБ = недостоверно»
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 3m56s
48db4991fc
Гейты #2582/#2589 стоят на входе и решают, пускать ли ЕКБ-only реестры.
Внешние провайдеры ими не покрыты: «реж, ленина» уходит в Nominatim/Yandex,
тот не находит Режа и отдаёт улицу Ленина в Екатеринбурге — с координатами
ЕКБ, пометкой «точно» и записью в кэш на 90 дней.

Отличить на входе «Реж» (город) от «Малышева» (улица) без списка городов
нельзя — оба «слово до запятой». ПОСЛЕ ответа можно: провайдер сам пишет,
какой НП он использовал. Отсюда проверка постфактум: назван топоним ≠
Екатеринбург, его имя не дожило до ответа провайдера, результат лежит внутри
ЕКБ (по координатам И по собственному ответу провайдера) ⇒ подмена.

Ни одного имени города в коде — только уровни РФ-адреса (страна → регион →
район → НП → улица → дом) и сам целевой город. Посёлки в городской черте
разделяются не географией, а сохранностью топонима: «Кедровка, Екатеринбург,
Советская» имя сохраняет и проходит, «Екатеринбург, Советская улица» на тот
же запрос — нет (настоящая Кедровка в 20 км от той улицы).

Проверка стоит одной точкой на выходе geocode() — покрывает все тиры разом,
включая попадание в кэш: 1251 отравленная запись перестаёт отдаваться как
точная без единого DELETE (обратимо откатом кода).

Последствие сработки — confidence="locality" + city_ambiguous=True. locality
не косметика: estimator._geocode_is_coarse включает #693 coarse-downgrade, а
tasks.geocode_missing ставит листингу geo_precision='city', что исключает его
из пула аналогов (geo_precision IS DISTINCT FROM 'city'). Координаты не
выбрасываются — деградация честная и видимая.

Замер на проде (geocode_cache, 9514 строк, 2026-08-12): 1251 запись подпадает
под инвариант — 1245 provider=yandex, 6 nominatim, 0 у локальных тиров;
365 разных населённых пунктов.

Refs #2590
bot-backend merged commit 677fcb749c into main 2026-08-12 14:26:58 +00:00
bot-backend deleted branch fix/2590-geocoder-city-invariant 2026-08-12 14:26:59 +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#2837
No description provided.