fix(tradein/geocoder): строгий матч литеры дома + починка DaData region-фильтра #2622
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
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: lekss361/gendesign#2622
Loading…
Add table
Reference in a new issue
No description provided.
Delete branch "fix/tradein-geocoder-house-letter"
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?
Проблема
_cadastral_house_matchсравнивал дом только по цифрам: литера была опциональна вWHERE([а-яё]?) и участвовала лишь как tie-break вORDER BY. Баг двусторонний и молчаливый:Оба результата шли с
confidence="exact"и оседали вgeocode_cacheна 90 дней → пользователь получал оценку чужого здания, помеченную как точная.Что сделано
1. Строгое равенство номера дома. Обе стороны приводятся к одному канону (
13б/13 б/13-б/13Б→13б): запрос — существующим_norm_house, реестр — тем же выражением на стороне Postgres (_SQL_HOUSE_TOKEN_RE+_SQL_HOUSE_TOKEN_NORM). Дешёвый regex-anchor по цифрам оставлен prefilter'ом (пушится в FDW, режет «(1-83)»-диапазоны) — корректность теперь на равенстве, а не на нём.Формы литеры взяты из реальных данных, не выдуманы — замер по
gendesign_cad_buildings(47k строк):д. 13/дом 13/сооружение 30д. 13бд. 13-бд. 58/3,д. 64-2(угловые)д. 13 бд. 102 корпус 1д. 11 (кв. 1-150)/(литера А)литера— 1, проигнорирована)Побочно закрыто той же логикой:
\m): раньшед\.?ловил «д» внутри «проезд» и «проезд 8 Марта, д 5» давал дом «8»;2. Промах ≠ тихая подстановка соседа. Нет дома с нужной литерой →
None, дальше отрабатывают следующие тиры.Почему None, а не пониженный confidence:
GeocodeSuggestionвообще не несётconfidence— его хардкодитgeocode()("exact"для всех трёх локальных тиров). Протаскивать новый канал уверенности через три тира ради этого случая — несоразмерно; фолбэк на внешние тиры и честнее, и дешевле. Проверено, что промах не утекает в соседний тир:_cadastral_forward_syncматчит литеральной подстрокой всего запроса, и «Новгородцевой 13б» не совпадает с «…Новгородцевой, дом 13» → 0 хитов → идём в DaData.3. DaData-тир глушился фильтром региона. В
locationsуходилоregion="Свердловская область", тогда как DaData хранит имя региона без типа (region="Свердловская", тип отдельно вregion_type="обл").locations— hard-filter, не boost: он не совпадал ни с чем и молча схлопывал выдачу в 0 подсказок без всякой ошибки. Именно поэтому фолбэк для «13б» не спасал.Добавлен
logger.warningна пустую выдачу под region-констрейнтом — следующая такая регрессия будет видна в логах, а не выглядеть как «DaData не знает этот адрес».4. Инвалидация кэша —
data/sql/203_purge_geocode_cache_house_letter.sql. Точечный DELETE: (а) в запросе была литера, либо (б) запроса без литеры с закэшированным домом с литерой. Не весь кэш — полная очистка сожгла бы квоту DaData (10k/день) на ре-резолв заведомо корректных адресов. Идемпотентно (чистый DELETE по предикату, повторный прогон удалит 0 строк), безопасно для strict exit-1 авто-применения. Номер 203 — коллизий нет.Проверено
SQL прогнан против живого реестра (
v_tradein_cad_buildings, 47k строк), не только на моках:Регрессия по охвату измерена: из 25 791 строки, проходивших старый prefilter, новую логику не проходят 20 — все вида «проезд 4-й ЕКАД Южный», т.е. достижимые ранее ТОЛЬКО через баг с
двнутри «проезд». Реальной потери покрытия нет.Предикат миграции прогнан против таблицы кейсов: 4 delete (обе стороны бага +
|city=-суффикс ключа) / 8 keep (корректные записи, «8 марта 204», «1-я пятилетки 5», «58/3», «64-2», хвост-слово).Тесты: переписан
test_house_match_passes_only_digits_as_regex_param(он закреплял баг:assert params["house_digits"] == "26"для входа26а) → теперь проверяет, что уходит полныйhouse_norm == "26а". Добавлено +42 теста: извлечение токена из 20 реальных форм реестра, матрица match/no-match по обоим направлениям бага, регистр и разделители, фолбэк до Nominatim, константа DaData. Семантика SQL зеркалится на Python из той же константы (\m→\b), поэтому правка регекспа автоматически меняет и проверки — рассинхрон невозможен.uv run pytest(полный,tradein-mvp/backend): 1 failed, 3123 passed, 9 skipped.Не проверено / честные оговорки
test_search_api.py::test_search_cache_hit(401) — пре-существующее. Прогнал полный suite на чистой базе (стэш): baseline тоже1 failed, 3081 passed. К этому изменению отношения не имеет. В изоляции этот файл вообще не собирается (ValidationError) — зависит от порядка в suite.region='Свердловская область'→ 0 (даже для «Малышева 30»),region='Свердловская'→ 3, первый «г Екатеринбург, ул Новгородцевой, д 13б» (56.8332628, 60.6753297);region=None+city='Екатеринбург'→ 5 включая «д 30Б»/«стр 30в». То есть токен валиден и DaData сам по себе работает — знает и отдаёт «13б»; ломал выдачу только формат значенияregionи на модель данных DaData (regionбез типа). Рассматривал альтернативу сkladr_id/region_iso_code— отверг: документацию по допустимым ключамlocationsподтвердить не удалось, а неверный ключ дал бы ровно тот же молчаливый ноль. Замер бьёт вывод из документации. Смоук после деплоя нужен._parse_street_houseсхлопывает его в «13», а «13» теперь строго не равно «13/2». Раньше результат был лотереей между «13» и «13/2»; фолбэк честнее. Чинить — значит править парсер, это за рамками.suggest()не подключал; модуль не рефакторил.`_cadastral_house_match` сравнивал дом только по ЦИФРАМ: литера была опциональна в WHERE (`[а-яё]?`) и участвовала лишь как tie-break в ORDER BY. Баг двусторонний и молчаливый: «Новгородцевой 13б» → «дом 13» (дома 13б в реестре нет вообще) «Малышева 30» → «д. 30-б» (обратное направление, живой прод-кейс) Оба результата возвращались с `confidence="exact"` и оседали в `geocode_cache` на 90 дней — пользователь получал оценку ЧУЖОГО здания, помеченную как точная. Почему так: номер дома теперь сравнивается РАВЕНСТВОМ нормализованных форм. Обе стороны приводятся к одному канону (`13б` / `13 б` / `13-б` / `13Б` → `13б`): запрос — существующим `_norm_house`, реестр — тем же выражением на стороне Postgres. Дешёвый regex-anchor по цифрам оставлен prefilter'ом (пушится в FDW, режет «(1-83)»-диапазоны), но корректность теперь на равенстве, а не на нём. Побочно закрыты той же логикой: • «58» больше не матчит «58/3», «13» не матчит «130» — угловой номер это часть номера дома, а не мусор; • маркер дома ищется только с начала слова (`\m`), иначе «проезд 8 Марта, д 5» давал дом «8» — старый `д\.?` ловил «д» внутри «проезд» (20 таких строк реестра были достижимы ТОЛЬКО через этот баг); • номер дома больше не конкатенируется в regex — regex-injection поверхность сузилась до цифр prefilter'а. Промаха не превращаем в тихую подстановку соседа: нет дома с нужной литерой → None, дальше отрабатывают следующие тиры (forward → DaData → Nominatim). Понижать confidence здесь нечем — `GeocodeSuggestion` его не несёт, все локальные тиры хардкодят "exact"; честный фолбэк дешевле, чем протаскивать новый канал уверенности через три тира. DaData-тир, который должен подхватывать такие адреса, был мёртв: в `locations` уходило `region="Свердловская область"`, тогда как DaData хранит имя региона БЕЗ типа (`region="Свердловская"`, `region_type="обл"`). Hard-filter не совпадал ни с чем и молча схлопывал выдачу в 0 подсказок (замер на проде: 0 хитов против 5 с «Свердловская», первый — искомый «д 13б» с fias_id). Добавлен warning на пустую выдачу под region- констрейнтом, чтобы следующая такая регрессия не была невидимой. Миграция 203 инвалидирует уже отравленные записи `geocode_cache` — точечно (запрос с литерой, либо запрос без литеры с закэшированным домом С литерой), а не весь кэш: полная очистка сожгла бы квоту DaData на ре-резолв заведомо корректных адресов. Проверено: SQL-выражение прогнано против живого реестра (47k строк) — «13б»→∅, «13»→дом 13, «30»→сооружение 30 (не «30-б»), «23б»→«д. 23-б»; предикат миграции — против таблицы кейсов (4 delete / 8 keep). Полный pytest: 1 failed, 3123 passed — падение `test_search_api.py::test_search_cache_hit` (401) воспроизводится и на чистом main (baseline 1 failed, 3081 passed), к этому изменению отношения не имеет. НЕ проверено вживую: ответ DaData с новым region-констрейнтом (нет доступа к прод-токену из этой сессии) — опираюсь на замер из отчёта об issue и на модель данных DaData.Поправка к описанию PR — не мержить, пока не принято решение
Проверено главной сессией по коду ветки
d63702cf, ревью подтверждено:В описании PR и в докстринге
_cadastral_house_matchзаявлена цепочка фолбэка «промах → forward-ILIKE → DaData → Nominatim». Вgeocode()тира DaData нет.Реальная цепочка
geocode(): cache → geoportal → cadastral → forward-ILIKE → Nominatim →return None._dadata_suggestживёт только вsuggest()— это автокомплит, отдельный путь.Что это меняет по факту
На прямом вызове
geocode()(API, PDF, восстановление по?id=— всё, что минует выбор из автокомплита) адрес с литерой, который не вытянули ни геопортал, ни Nominatim, теперь вернётNone→ оценка не построится вообще, тогда как раньше строилась по соседнему дому.Это защитимое поведение: молчаливая неверная оценка с меткой
confidence="exact"хуже честного отказа, и это прямо соответствует honesty-gates из стратегического аудита. Но это продуктовое решение, а не техническая деталь, и обосновывать мерж страховкой, которой не существует, нельзя.Собственный новый тест автора
test_geocode_letter_house_miss_reaches_nominatimмокает именно реальную цепочку и заканчивается на Nominatim — то есть тест доказывает обратное тому, что написано в описании.Что нужно до мержа
_cadastral_house_match:1019-1020— убрать DaData из заявленной цепочкиgeocode().suggest()до этого PR возвращал 0 подсказок на любой запрос и вживую после правки не проверялся (прод-токен был недоступен исполнителю). До смоука тир считать непроверенным, а не исправленным.Ядро фикса (строгое сравнение нормализованных форм номера дома вместо «цифры совпали, литера опциональна») ревью подтвердило прогоном SQL против живого реестра:
13б→∅,13→дом 13,30→сооружение 30 вместо «30-б»,23б→«д. 23-б». Регрессия покрытия измерена: из 25 791 строки старого prefilter'а новую логику не проходят 20, все — «проезд N-й», достижимые ранее только через сам баг.Решение владельца принято — мержим
Поведение «нет точного дома с литерой → отказ вместо оценки соседнего» утверждено владельцем 02.08.2026 как продуктовое решение.
Область действия узкая: прямой вызов
geocode()без координат — API, PDF, восстановление по?id=. Основной UI-сценарий не затронут: координаты приходят из выбранной подсказки (ParamsPanel.tsx:776→trade_in.py:128используетlat/lonнапрямую),geocode()в нём не участвует.Обоснование: уверенно-неверная оценка чужого здания с меткой
confidence="exact", оседающая вgeocode_cacheна 90 дней, хуже честного отказа. Соответствует honesty-gates стратегического аудита.Pre-merge: все 7 проверок зелёные на
9f03733e, включая явныйCI Trade-In / backend-testsиCI / backend-tests.Post-deploy обязательно (в PR остаётся непроверенным):
region.203_purge_geocode_cache_house_letter.sqlприменилась и почистила только целевые записи кэша.Второй PR (#2621, атрибуция Leaflet) мержится отдельно, после завершения деплоя этого — оба триггерят
deploy-tradein.yml, а два мержа подряд роняютtest-job и дают «успешный деплой» на старом образе.