fix(tradein/geocode): прошить city_hint в deals-скрипт + развести счётчики гейта (#2603) #2655

Merged
bot-backend merged 3 commits from fix/2603-geocode-city-hint-tails into main 2026-08-05 15:29:20 +00:00
Collaborator

Что было

Два хвоста после #2601 плюс две мелочи.

  1. scripts/geocode_deals_nominatim.py — непрошитый sibling-caller. Группировка GROUP BY address, вызов geocode(address, db) без города. deals.city (миграция 177) заполнена на 100%, но выбрасывалась: один и тот же текст адреса из разных городов схлопывался в одну группу → один geocode-вызов → один UPDATE по тексту адреса, задевающий обе стороны. Ровно та форма, которую #2601 починил в app/tasks/geocode_missing.py.
  2. Счётчик skipped_non_ekb в app/tasks/backfill_listings_coords_geoportal.py смешивал колоночный и текстовый гейты. Колоночный стоит ПЕРЕД парсером адреса, поэтому по мере раскатки областных развёрток (#2598) строки начнут перетекать из no_address в skipped_non_ekb — счётчик поменял бы смысл ровно тогда, когда по нему хотят валидировать раскатку.
  3. test_admin_geocode_missing_passes_city_hint покрывал только target="listings".
  4. Dry-run лог geocode_missing.py не печатал city, хотя с #2594 это часть ключа группы.

Что стало

1. deals-скрипт — та же форма, что в #2601:

  • SELECT address, city ... GROUP BY address, city, window/outer ORDER BY синхронизированы (..., address ASC, city ASC NULLS FIRST) — иначе running_rows-кап режет другой порядок.
  • geocode(address, db, city_hint=...).
  • Оба writer'а (_update_deals_geocoded, _mark_deals_tried) скоупятся city IS NOT DISTINCT FROM CAST(:city AS text) — обычное = никогда не истинно для NULL-города, NULL-группа не обновлялась бы вовсе и переотбиралась бы вечно.
  • _count_pending_total считает ПАРЫ (знаменатель dry-run-проекции сходится с числителем).
  • city добавлен в dry-run/warning-логи скрипта.

2. Откуда взят набор городов и почему. Хинт передаётся ТОЛЬКО для значений из geocoder.SVERDLOVSK_OBLAST_CITIES (_city_hint), иначе city_hint=None. Это существующий источник истины в репозитории, а не новый список: тот же frozenset уже питает гейты самого геокодера (_names_non_ekb_city, _ekb_local_tiers_allowed, _has_oblast_marker) и estimator._resolve_target_city (city-scoping ДКП-коридора, см. миграцию 205). Свой список дрейфовал бы от гейтов, в которые этот же хинт и приходит.

Почему фильтр обязателен: deals.city — росреестровое поле, в хвосте распределения лежит мусор (Бессонова, Бердюгина, Билейский рыбопитомник). Любой не-ЕКБ хинт жёстко закрывает EKB-локальные тиры и подставляется префиксом в запрос провайдеру, т.е. мусорный хинт делает результат ХУЖЕ, чем его отсутствие. Нераспознанное значение → поведение как раньше (без хинта), но UPDATE всё равно скоупится по паре.

3. Счётчики гейта. Добавлен skipped_non_ekb_by_column (dataclass + to_counters() + оба DONE-лога). Обратная совместимость: skipped_non_ekb продолжает означать то же, что и раньше (гейт целиком), новый счётчик — его подмножество; вклад текстового гейта = разность, т.е. метрика #2583 остаётся вычислимой.

4. Мелочи. Тест admin-эндпоинта параметризован на ["listings", "deals"] (+ проверка, что SELECT адресован запрошенной таблице). Dry-run лог geocode_missing.py печатает city.

Тесты

Прогон локально, uv sync --frozen + uv run pytest (как в CI deploy-tradein.yml):

  • Затронутые модули: 75 passed (tests/scripts/test_geocode_deals_nominatim.py, tests/tasks/test_backfill_listings_coords_geoportal.py, tests/tasks/test_geocode_missing.py).
  • Полный сьют tradein-mvp/backend с CI-деселектом: 3316 passed, 9 skipped, 1 deselected (tests/test_search_api.py::test_search_cache_hit — pre-existing, деселектится и в CI).
  • Falsification (implementation застэшена, тесты новые): tests/scripts/test_geocode_deals_nominatim.py не собирается (ImportError: _city_hint), test_skipped_non_ekb_by_column_counts_only_column_gate и test_to_counters_exposes_skipped_non_ekb_by_column падают. Честная оговорка: параметризация admin-теста на deals проходит и на старом коде — это добор покрытия ветки, а не фикс поведения (сам прокид city_hint для deals приехал ещё в #2601).

Новые тесты: группировка по паре и city в AddressGroup; _city_hint (известный город → передаётся, мусор/пусто → None); один и тот же адрес в двух городах → 2 вызова со своими хинтами и 2 скоупленных UPDATE; miss помечает только свой город; IS NOT DISTINCT FROM в обоих writer'ах, включая NULL-группу; разделение счётчиков + ключ в to_counters().

Что НЕ входит

  • app/services/geocoder.py не тронут (только импорт константы) — как и в #2601.
  • Миграций нет, схема не менялась.
  • Замечено, но вне скоупа #2603: POST /admin/geocode-missing?target=deals (app/api/v1/admin.py, приехало в #2601) отдаёт row["city"] в city_hint БЕЗ словарной валидации — тот же класс риска «мусорный росреестровый хинт», что чинится здесь в скрипте. Не трогал, чтобы не расширять скоуп задачи; если нужно — вынести _city_hint в общее место отдельным PR.
  • COUNT(DISTINCT (address, COALESCE(city, ''))) в dry-run-знаменателе: COALESCE намеренный — чтобы не зависеть от спора о том, считает ли count() row-выражение с NULL-полем; на живой БД не проверял (задача чисто кодовая, к проду не ходил).

Refs #2603

## Что было Два хвоста после #2601 плюс две мелочи. 1. **`scripts/geocode_deals_nominatim.py` — непрошитый sibling-caller.** Группировка `GROUP BY address`, вызов `geocode(address, db)` без города. `deals.city` (миграция 177) заполнена на 100%, но выбрасывалась: один и тот же текст адреса из разных городов схлопывался в одну группу → один geocode-вызов → один UPDATE по тексту адреса, задевающий обе стороны. Ровно та форма, которую #2601 починил в `app/tasks/geocode_missing.py`. 2. **Счётчик `skipped_non_ekb`** в `app/tasks/backfill_listings_coords_geoportal.py` смешивал колоночный и текстовый гейты. Колоночный стоит ПЕРЕД парсером адреса, поэтому по мере раскатки областных развёрток (#2598) строки начнут перетекать из `no_address` в `skipped_non_ekb` — счётчик поменял бы смысл ровно тогда, когда по нему хотят валидировать раскатку. 3. `test_admin_geocode_missing_passes_city_hint` покрывал только `target="listings"`. 4. Dry-run лог `geocode_missing.py` не печатал `city`, хотя с #2594 это часть ключа группы. ## Что стало **1. deals-скрипт — та же форма, что в #2601:** - `SELECT address, city ... GROUP BY address, city`, window/outer `ORDER BY` синхронизированы (`..., address ASC, city ASC NULLS FIRST`) — иначе `running_rows`-кап режет другой порядок. - `geocode(address, db, city_hint=...)`. - Оба writer'а (`_update_deals_geocoded`, `_mark_deals_tried`) скоупятся `city IS NOT DISTINCT FROM CAST(:city AS text)` — обычное `=` никогда не истинно для NULL-города, NULL-группа не обновлялась бы вовсе и переотбиралась бы вечно. - `_count_pending_total` считает ПАРЫ (знаменатель dry-run-проекции сходится с числителем). - `city` добавлен в dry-run/warning-логи скрипта. **2. Откуда взят набор городов и почему.** Хинт передаётся ТОЛЬКО для значений из `geocoder.SVERDLOVSK_OBLAST_CITIES` (`_city_hint`), иначе `city_hint=None`. Это существующий источник истины в репозитории, а не новый список: тот же frozenset уже питает гейты самого геокодера (`_names_non_ekb_city`, `_ekb_local_tiers_allowed`, `_has_oblast_marker`) и `estimator._resolve_target_city` (city-scoping ДКП-коридора, см. миграцию 205). Свой список дрейфовал бы от гейтов, в которые этот же хинт и приходит. Почему фильтр обязателен: `deals.city` — росреестровое поле, в хвосте распределения лежит мусор (`Бессонова`, `Бердюгина`, `Билейский рыбопитомник`). Любой не-ЕКБ хинт жёстко закрывает EKB-локальные тиры и подставляется префиксом в запрос провайдеру, т.е. мусорный хинт делает результат ХУЖЕ, чем его отсутствие. Нераспознанное значение → поведение как раньше (без хинта), но UPDATE всё равно скоупится по паре. **3. Счётчики гейта.** Добавлен `skipped_non_ekb_by_column` (dataclass + `to_counters()` + оба DONE-лога). Обратная совместимость: `skipped_non_ekb` продолжает означать то же, что и раньше (гейт целиком), новый счётчик — его подмножество; вклад текстового гейта = разность, т.е. метрика #2583 остаётся вычислимой. **4. Мелочи.** Тест admin-эндпоинта параметризован на `["listings", "deals"]` (+ проверка, что SELECT адресован запрошенной таблице). Dry-run лог `geocode_missing.py` печатает `city`. ## Тесты Прогон локально, `uv sync --frozen` + `uv run pytest` (как в CI `deploy-tradein.yml`): - Затронутые модули: **75 passed** (`tests/scripts/test_geocode_deals_nominatim.py`, `tests/tasks/test_backfill_listings_coords_geoportal.py`, `tests/tasks/test_geocode_missing.py`). - Полный сьют `tradein-mvp/backend` с CI-деселектом: **3316 passed, 9 skipped, 1 deselected** (`tests/test_search_api.py::test_search_cache_hit` — pre-existing, деселектится и в CI). - Falsification (implementation застэшена, тесты новые): `tests/scripts/test_geocode_deals_nominatim.py` не собирается (`ImportError: _city_hint`), `test_skipped_non_ekb_by_column_counts_only_column_gate` и `test_to_counters_exposes_skipped_non_ekb_by_column` падают. Честная оговорка: параметризация admin-теста на `deals` проходит и на старом коде — это добор покрытия ветки, а не фикс поведения (сам прокид `city_hint` для deals приехал ещё в #2601). Новые тесты: группировка по паре и `city` в `AddressGroup`; `_city_hint` (известный город → передаётся, мусор/пусто → None); один и тот же адрес в двух городах → 2 вызова со своими хинтами и 2 скоупленных UPDATE; miss помечает только свой город; `IS NOT DISTINCT FROM` в обоих writer'ах, включая NULL-группу; разделение счётчиков + ключ в `to_counters()`. ## Что НЕ входит - `app/services/geocoder.py` не тронут (только импорт константы) — как и в #2601. - Миграций нет, схема не менялась. - **Замечено, но вне скоупа #2603:** `POST /admin/geocode-missing?target=deals` (`app/api/v1/admin.py`, приехало в #2601) отдаёт `row["city"]` в `city_hint` БЕЗ словарной валидации — тот же класс риска «мусорный росреестровый хинт», что чинится здесь в скрипте. Не трогал, чтобы не расширять скоуп задачи; если нужно — вынести `_city_hint` в общее место отдельным PR. - `COUNT(DISTINCT (address, COALESCE(city, '')))` в dry-run-знаменателе: `COALESCE` намеренный — чтобы не зависеть от спора о том, считает ли `count()` row-выражение с NULL-полем; на живой БД не проверял (задача чисто кодовая, к проду не ходил). Refs #2603
bot-backend added 1 commit 2026-08-05 12:37:06 +00:00
fix(tradein/geocode): прошить city_hint в deals-скрипт + развести счётчики гейта (#2603)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 8s
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 2m46s
ec17886d60
Хвосты после #2601 (замыкание петли «город → геокодер»).

1. scripts/geocode_deals_nominatim.py — непрошитый sibling-caller.
   Скрипт группировал `GROUP BY address` и звал `geocode(address, db)` без
   города, хотя deals.city (миграция 177) заполнена на 100%: один и тот же
   текст адреса из разных городов схлопывался в одну группу, один geocode-вызов
   и один UPDATE по тексту адреса. Теперь — та же форма, что в #2601:
   группировка по паре (address, city), city_hint в geocode(), UPDATE и
   mark-tried через `city IS NOT DISTINCT FROM` (обычное `=` не ловит NULL-город
   → NULL-группа не обновлялась бы вовсе).

   Хинт передаётся ТОЛЬКО для значений из geocoder.SVERDLOVSK_OBLAST_CITIES:
   deals.city росреестровое, в хвосте лежит мусор («Бессонова», «Бердюгина»,
   «Билейский рыбопитомник»), а любой не-ЕКБ хинт жёстко закрывает EKB-локальные
   тиры и подставляется в запрос провайдеру — мусорный хинт хуже отсутствия
   хинта. Словарь переиспользован, а не заведён свой: тот же набор уже питает
   гейты самого геокодера (_names_non_ekb_city / _ekb_local_tiers_allowed) и
   estimator._resolve_target_city.

2. tasks/backfill_listings_coords_geoportal.py — наблюдаемость городского гейта.
   Добавлен skipped_non_ekb_by_column (+ в to_counters и в DONE-логи): колоночный
   гейт стоит перед парсером адреса, поэтому по мере раскатки областных
   развёрток (#2598) строки потекут из no_address в skipped_non_ekb и общий
   счётчик поменяет смысл ровно тогда, когда по нему валидируют раскатку.
   Старый счётчик не тронут — остаётся суммой обоих гейтов, вклад текстового
   считается разностью.

3. tests: test_admin_geocode_missing_passes_city_hint параметризован на
   target="deals" (колонка city есть в обеих таблицах, ветка была не покрыта).

4. tasks/geocode_missing.py: dry-run лог печатает city — он с #2594 часть ключа
   группы, без него две строки dry-run неотличимы.

Refs #2603
Light1YT added 1 commit 2026-08-05 15:20:46 +00:00
fix(tradein/geocode): гейт мусорного города вынести в общий хелпер и прошить в admin-путь (#2603)
All checks were successful
CI / changes (pull_request) Successful in 7s
CI Trade-In / changes (pull_request) Successful in 7s
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 2m42s
5ecd5361fd
Первый коммит починил только scripts/geocode_deals_nominatim.py — ручной скрипт.
Тот же дефект оставался на живом пути: POST /admin/geocode-missing?target=deals
отдавал сырой row["city"] в city_hint, а deals.city росреестровое и в хвосте
распределения содержит не-города («Бессонова», «Билейский рыбопитомник»). Любой
не-ЕКБ хинт жёстко закрывает EKB-локальные тиры и уезжает префиксом в запрос
провайдеру, то есть мусорный хинт хуже отсутствия хинта.

Гейт вынесен в geocoder.known_city_hint (сверка с SVERDLOVSK_OBLAST_CITIES —
тем же набором, который уже питает _names_non_ekb_city / _ekb_local_tiers_allowed)
и переиспользуется всеми тремя потребителями city_hint: скриптом, admin-ручкой и
задачей geocode_missing. Копий функции нет — четвёртый потребитель, если появится,
получит гейт сам.

Тесты: мусорный город -> хинт не передаётся, валидный -> передаётся; проверено
фальсификацией (без фикса все три новых теста краснеют).
Light1YT added 1 commit 2026-08-05 15:25:55 +00:00
Merge remote-tracking branch 'origin/main' into fix/2603-geocode-city-hint-tails
All checks were successful
CI / changes (pull_request) Successful in 7s
CI Trade-In / changes (pull_request) Successful in 8s
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 2m41s
17fcf746f7
# Conflicts:
#	tradein-mvp/backend/app/api/v1/admin.py
bot-backend merged commit 0a001ee3f7 into main 2026-08-05 15:29:20 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
2 participants
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#2655
No description provided.