fix(tradein/geocode): передавать город объявления как city_hint в геокодирование (#2594) #2601

Merged
lekss361 merged 1 commit from fix/tradein-geocode-city-hint into main 2026-07-31 21:40:37 +00:00
Owner

Что сделано

Замыкает петлю "город объявления -> геокодирование" (issue #2594, шаг 2/3). Колонка listings.city (миграция 196, PR #2598) уже заполняется скрапером из контекста развёртки, но три caller-места геокодера решали город по ТЕКСТУ адреса и игнорировали колонку — голый адрес без города в тексте ("ул. Победы, 30", тагильский) уходил в Екатеринбург. geocoder.py::geocode(address, db, city_hint=None) УЖЕ умеет принимать и корректно обрабатывать city_hint — он НЕ тронут (см. ниже).

1. app/tasks/geocode_missing.py

  • SELECT группирует по паре (address, city) вместо только address — один и тот же текст адреса в разных городах теперь две разные группы с раздельными geocode-вызовами.
  • geocode(address, db, city_hint=city).
  • UPDATE (успех и tried_at-пометка при неудаче) — по паре: WHERE address = :addr AND city IS NOT DISTINCT FROM :city AND lat IS NULL.
  • ORDER BY / LIMIT / loop-safe 7-дневный backoff — семантика сохранена, добавлен только city в GROUP BY/ORDER BY/WHERE.

2. app/tasks/backfill_listings_coords_geoportal.py

  • Добавлен гейт по колонке city ПЕРЕД матчем против EKB-only ekb_geoportal_buildings И перед существующим текстовым гейтом _names_non_ekb_city: если city IS NOT NULL AND city <> 'Екатеринбург' → skip (считается в существующий счётчик skipped_non_ekb).
  • Текстовый гейт СОХРАНЁН как fallback для листингов с city IS NULL (записаны до миграции 196 либо путём, не проставляющим город — admin ad-hoc /admin/scrape).
  • Это окно идёт ПЕРЕД geocode_missing_listings в ночном расписании — без гейта по колонке оно успевало испортить координаты первым (проставить ЕКБ-координаты тагильскому дому с совпадающими street+house до того, как geocode_missing_listings вообще получал шанс).

3. app/api/v1/admin.py (POST /geocode-missing, per-ID endpoint)

  • SELECT теперь читает city (колонка есть в обеих целевых таблицах — listings.city миграция 196, deals.city миграция 177) и передаёт как city_hint в geocode().

Почему IS NOT DISTINCT FROM, а не =

Стандартная SQL NULL-семантика: city = NULL никогда не true — обычным = группа с city IS NULL не обновилась бы вообще ни для одной строки. IS NOT DISTINCT FROM трактует NULL = NULL как совпадение, оставаясь строгим при непустом city — ровно нужная симметрия: NULL-город — отдельная группа, а не "любой город".

geocoder.py не тронут

Запрещено ТЗ (переработан дважды подряд, #2580/#2582/#2589). Подтверждение: git diff forgejo/main -- tradein-mvp/backend/app/services/geocoder.py — пусто.

Тесты (falsification-прогон)

На каждое из трёх мест — тест, падающий на старом коде и проходящий после правки. Ключевой сценарий (geocode_missing): две записи с ОДИНАКОВЫМ текстом адреса и РАЗНЫМИ городами — каждая получает свои координаты, UPDATE одной не задевает другую.

Процедура: git stash push только трёх implementation-файлов (тесты остались нетронутыми) → прогон → git stash pop → повторный прогон.

  • До фикса (implementation застэшена, тесты новые): 7 failed, 36 passed — все 7 фейлов это новые тесты на новое поведение (geocode_missing.py — 4, admin.py — 2, backfill_listings_coords_geoportal.py — 1).
  • После фикса (implementation восстановлена): 43 passed, 0 failed.

Полный pytest (tradein-mvp/backend)

2970 passed, 1 failed, 9 skipped — единственный фейл tests/test_search_api.py::test_search_cache_hit (pre-existing 401 от RBAC-мидлвари, к задаче не относится, не чинил по инструкции).

Refs #2594

## Что сделано Замыкает петлю "город объявления -> геокодирование" (issue #2594, шаг 2/3). Колонка `listings.city` (миграция 196, PR #2598) уже заполняется скрапером из контекста развёртки, но три caller-места геокодера решали город по ТЕКСТУ адреса и игнорировали колонку — голый адрес без города в тексте ("ул. Победы, 30", тагильский) уходил в Екатеринбург. `geocoder.py::geocode(address, db, city_hint=None)` УЖЕ умеет принимать и корректно обрабатывать `city_hint` — он НЕ тронут (см. ниже). ### 1. `app/tasks/geocode_missing.py` - SELECT группирует по паре `(address, city)` вместо только `address` — один и тот же текст адреса в разных городах теперь две разные группы с раздельными geocode-вызовами. - `geocode(address, db, city_hint=city)`. - UPDATE (успех и tried_at-пометка при неудаче) — по паре: `WHERE address = :addr AND city IS NOT DISTINCT FROM :city AND lat IS NULL`. - ORDER BY / LIMIT / loop-safe 7-дневный backoff — семантика сохранена, добавлен только `city` в GROUP BY/ORDER BY/WHERE. ### 2. `app/tasks/backfill_listings_coords_geoportal.py` - Добавлен гейт по колонке `city` ПЕРЕД матчем против EKB-only `ekb_geoportal_buildings` И перед существующим текстовым гейтом `_names_non_ekb_city`: если `city IS NOT NULL AND city <> 'Екатеринбург'` → skip (считается в существующий счётчик `skipped_non_ekb`). - Текстовый гейт СОХРАНЁН как fallback для листингов с `city IS NULL` (записаны до миграции 196 либо путём, не проставляющим город — admin ad-hoc `/admin/scrape`). - Это окно идёт ПЕРЕД `geocode_missing_listings` в ночном расписании — без гейта по колонке оно успевало испортить координаты первым (проставить ЕКБ-координаты тагильскому дому с совпадающими street+house до того, как `geocode_missing_listings` вообще получал шанс). ### 3. `app/api/v1/admin.py` (`POST /geocode-missing`, per-ID endpoint) - SELECT теперь читает `city` (колонка есть в обеих целевых таблицах — `listings.city` миграция 196, `deals.city` миграция 177) и передаёт как `city_hint` в `geocode()`. ### Почему `IS NOT DISTINCT FROM`, а не `=` Стандартная SQL NULL-семантика: `city = NULL` никогда не `true` — обычным `=` группа с `city IS NULL` не обновилась бы вообще ни для одной строки. `IS NOT DISTINCT FROM` трактует `NULL = NULL` как совпадение, оставаясь строгим при непустом `city` — ровно нужная симметрия: NULL-город — отдельная группа, а не "любой город". ### `geocoder.py` не тронут Запрещено ТЗ (переработан дважды подряд, #2580/#2582/#2589). Подтверждение: `git diff forgejo/main -- tradein-mvp/backend/app/services/geocoder.py` — пусто. ## Тесты (falsification-прогон) На каждое из трёх мест — тест, падающий на старом коде и проходящий после правки. Ключевой сценарий (`geocode_missing`): две записи с ОДИНАКОВЫМ текстом адреса и РАЗНЫМИ городами — каждая получает свои координаты, UPDATE одной не задевает другую. Процедура: `git stash push` только трёх implementation-файлов (тесты остались нетронутыми) → прогон → `git stash pop` → повторный прогон. - **До фикса** (implementation застэшена, тесты новые): `7 failed, 36 passed` — все 7 фейлов это новые тесты на новое поведение (`geocode_missing.py` — 4, `admin.py` — 2, `backfill_listings_coords_geoportal.py` — 1). - **После фикса** (implementation восстановлена): `43 passed, 0 failed`. ## Полный pytest (`tradein-mvp/backend`) `2970 passed, 1 failed, 9 skipped` — единственный фейл `tests/test_search_api.py::test_search_cache_hit` (pre-existing 401 от RBAC-мидлвари, к задаче не относится, не чинил по инструкции). Refs #2594
lekss361 added 1 commit 2026-07-31 20:49:45 +00:00
fix(tradein/geocode): передавать город объявления как city_hint в геокодирование (#2594)
All checks were successful
CI / changes (pull_request) Successful in 11s
CI Trade-In / changes (pull_request) Successful in 11s
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 2m38s
41e3cb906b
Замыкает петлю "город объявления -> геокодирование" (issue #2594, шаг 2/3).
listings.city (миграция 196) заполняется скрапером из контекста развёртки, но
три caller-места геокодера решали город по ТЕКСТУ адреса и игнорировали
колонку - голый адрес без города в тексте ("ул. Победы, 30", тагильский)
уходил в Екатеринбург.

- app/tasks/geocode_missing.py: группировка по (address, city) вместо
  address, city_hint в geocode(), UPDATE/tried_at-пометка по паре через
  city IS NOT DISTINCT FROM :city (обычный `=` не поймал бы NULL-город и
  не даёт нужной симметрии между группами).
- app/tasks/backfill_listings_coords_geoportal.py: гейт по колонке city
  ПЕРЕД матчем против EKB-only ekb_geoportal_buildings, ПЕРЕД текстовым
  гейтом _names_non_ekb_city (сохранён как fallback для city IS NULL).
  Это окно идёт раньше geocode_missing_listings, поэтому раньше успевало
  испортить координаты первым.
- app/api/v1/admin.py: per-ID endpoint /geocode-missing читает city из
  SELECT (listings.city / deals.city) и передаёт как city_hint.

geocoder.py не тронут (запрещено ТЗ).

Тесты: falsification-прогон (stash impl, тесты остаются) - 7 failed / 36
passed на старом коде, все 7 - новые тесты на новое поведение; после
stash pop - 43 passed / 0 failed. Полный pytest tradein-mvp/backend:
2970 passed, 1 failed (pre-existing tests/test_search_api.py::test_search_cache_hit,
несвязан), 9 skipped.
lekss361 merged commit c38cf611cd into main 2026-07-31 21:40:37 +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#2601
No description provided.