fix(tradein/geocoder): инвариант «чужой топоним + результат внутри ЕКБ = недостоверно» (#2590) #2837
No reviewers
Labels
No labels
Fable 5 ревью
GG-форсайт
admin
analytics
auth
automation
bug
business
chore
ci
compliance
data
data-moat
docs
duplicate
dx
enhancement
feedback/max
generative
needs-discussion
needs-human
observability
pause-bots
performance
priority/p0
priority/p1
priority/p2
priority/p3
scope/backend
scope/db
scope/devops
scope/frontend
scope/qa
scrapers
security
site-finder
stage/1
stage/2
status/blocked
status/done
status/needs-analysis
status/needs-fix
status/qa
status/ready
status/review
status/wip
tech-debt
tradein
ux
week ревью 1
wontfix
ИРД
вторичка
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: lekss361/gendesign#2837
Loading…
Add table
Reference in a new issue
No description provided.
Delete branch "fix/2590-geocoder-city-invariant"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Refs #2590. Закрывает последний канал подмены города — внешние провайдеры, которые гейты #2582/#2589 не покрывают ни до, ни после.
Замер на проде (до фикса)
geocode_cache, 9514 строк, 2026-08-12 — прогон шипаемым кодом, не прототипом: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)— три условия вместе:EKB_BBOX_TIGHT), и по собственному ответу провайдера (он называет Екатеринбург либо не называет НП вовсе).Имён городов в коде нет — только уровни адреса и целевой город. Единственная константа-топоним помимо ЕКБ — «Россия» (страна, у неё не появляется новых значений, в отличие от списка городов области).
Почему постфактум, а не на входе. На входе «Реж» и «Малышева» структурно неразличимы — оба «слово до запятой»; закрыть по этому признаку гейт значило бы сломать основной сценарий ввода без города (ровно тот компромисс, что зафиксирован как известная дыра в #2589). После ответа они различимы: провайдер сам пишет, какой НП использовал.
Что с посёлками в городской черте
Разделяет их не география, а сохранность топонима. «пос. Кедровка, Советская ул., 5»:
То есть посёлок не выбрасывается за то, что он посёлок — граница из #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 не закрыта.locality— 6 топонимов из 365. Цена ошибки асимметрична: ложная отбраковка = честное «не уверены в городе», пропуск = уверенно неверная цена.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'), при этом контроли — ЕКБ-адрес и честный ответ по Кедровке — проходят и на старом коде. Тест не тавтологичен: он падает на дефекте и молчит на нормеruff check/ruff formatчисто,scripts/check-migration-lock-timeout.py→✓ блокирующий DDL прикрыт lock_timeout (проверено новых миграций: 6)