fix(tradein/houses): вернуть координаты объявлений в дом, когда объявления согласны (#2771) #2780

Merged
bot-backend merged 1 commit from fix/2771-house-coords-from-listings into main 2026-08-07 09:08:07 +00:00
Collaborator

Summary

Дом берёт координаты один раз — при создании, из породившего его объявления. Объявление геокодируется позже (geoportal_coords_backfill, geocode_missing_listings — обе живые) и пишет в listings; ON CONFLICT у вставки дома обновляет только address. Обратно координаты не возвращались.

Новая ежедневная задача house_coords_from_listings (окно 03:00-04:00 UTC — перед недельным house_dedup_merge) берёт для дома с lat IS NULL центроид geom его объявлений и разброс = max ST_DistanceSphere от центроида до объявления. Разброс <= 125 м → центроид пишется в дом.

Что это даёт дедупу. Не «разблокирует слияния». Сейчас гео-ограждение house_dedup_merge отбраковывает проигравших с NULL geom, потому что проверить тождество нечем. После переноса у ограждения появляется возможность оценить пару: часть сольётся, часть будет отвергнута правильно (дома в разных городах). Рост числа слияний здесь не обещается и не закладывается.

Порог 125 м — откуда

Перемерено на проде 2026-08-07 честно (ST_DistanceSphere/geography), а не приближением «градусы × 111320». Гистограмма разброса по 258 домам с >1 различной точкой обрыва не даёт: плотное ядро 0-125 м (150 домов), затем ровное плато ~4-5 домов на каждые 25 м вплоть до 400 м, и масса из 67 домов дальше 400 м. Раз в данных порога нет, он взят от потребителя: гео-ограждение house_dedup_merge (#2187) считает два дома одним зданием при ST_DistanceSphere <= 250. Записанный центроид — оценка с погрешностью ровно в разброс; отдать ей весь бюджет ограждения значит позволить двум домам разъехаться на 500 м и сломать сравнение. Половина бюджета → 125 м.

Решение не несущее: 125 м пропускает 1382 дома, 250 м — 1403. Разница в 21 дом не теряется, а откладывается до следующего прогона.

Замер ДО — тем же SQL, что мержится

_BUILD_CANDIDATES_SQL + _COUNTERS_SQL из самого модуля, прогнаны на проде read-only (BEGIN/ROLLBACK):

houses_total 9625 | with_coords 7680 | without_coords 1945 (79.8%)

candidates 1945
├─ eligible                   1382   → получат координаты
├─ skipped_no_listings         114   объявлений нет вовсе
├─ skipped_no_listing_coords   341   объявления есть, но сами без координат
└─ skipped_scattered           108   разброс > 125 м (18 дальше 5 км, худший 289.9 км)

Ожидание ПОСЛЕ первого прогона: 7680 → 9062 домов с координатами (79.8% → 94.2%), без координат 1945 → 563.

Пропуск не молчит

Счётчики разделены по причинам (skipped_no_listings / skipped_no_listing_coords / skipped_scattered / scattered_over_5km / worst_spread_m), а худшие дома с разбросом называются в логе поимённо — иначе усечение читается как «покрыто всё». Разбросанные дома — дефект сопоставителя, вынесен отдельной работой: #2777 (108 домов, 2663 объявления, худший «ул. Кирова,4» — 5 объявлений с радиусом 289.9 км).

Test plan

  • tests/tasks/test_backfill_house_coords_from_listings.py — 8 статических + поведенческий на живом PostGIS: 5 домов, 5 исходов (перенос центроида / пропуск дома с объявлениями в 11 км / объявления без geom / нет объявлений / уже есть координаты — не перезаписаны), второй прогон no-op.
  • Красный на старом коде: тот же файл на origin/mainImportError: cannot import name 'backfill_house_coords_from_listings' — механизма нет.
  • Мутация (проверка, что тест не тавтологичен): убрать AND c.spread_m <= ... из UPDATE → дом с объявлениями в 11 км получает середину между двумя городами (56.89) → тест краснеет.
  • Полный сьют бэкенда на PostGIS 16-3.4 (как в CI): 3983 passed, 1 skipped.
  • ruff check + ruff format, grep -nE ':[a-z_]+::[a-z]' пуст (psycopg v3).

Прод-верификация (критерий записан ДО факта)

Задача по расписанию. Ближайший штатный прогон — 03:00-04:00 UTC следующих суток после деплоя; для немедленного смоука UPDATE scrape_schedules SET next_run_at=now() WHERE source='house_coords_from_listings' (подхват <= 60 с, планировщик в tradein-scraper).

Критерий приёмки: после первого прогона SELECT count(*) FROM houses WHERE lat IS NULL = 563 ± 20 (было 1945), а scrape_runs последнего прогона несёт updated ≈ 1382, skipped_scattered = 108, errors = 0. Отдельно: у дома 380628 («ул. Кирова,4») lat остаётся NULL — он обязан быть пропущен.

Порядок деплоя безопасен: миграция 239 сеет next_run_at на завтра 03:00 UTC, поэтому старый образ не встретит источник без handler'а.

Closes #2771

## Summary Дом берёт координаты **один раз** — при создании, из породившего его объявления. Объявление геокодируется позже (`geoportal_coords_backfill`, `geocode_missing_listings` — обе живые) и пишет в `listings`; `ON CONFLICT` у вставки дома обновляет только `address`. Обратно координаты не возвращались. Новая ежедневная задача `house_coords_from_listings` (окно 03:00-04:00 UTC — перед недельным `house_dedup_merge`) берёт для дома с `lat IS NULL` центроид geom его объявлений и **разброс** = max `ST_DistanceSphere` от центроида до объявления. Разброс <= 125 м → центроид пишется в дом. **Что это даёт дедупу.** Не «разблокирует слияния». Сейчас гео-ограждение `house_dedup_merge` отбраковывает проигравших с NULL geom, потому что проверить тождество нечем. После переноса у ограждения появляется **возможность оценить** пару: часть сольётся, часть будет отвергнута правильно (дома в разных городах). Рост числа слияний здесь не обещается и не закладывается. ## Порог 125 м — откуда Перемерено на проде 2026-08-07 честно (`ST_DistanceSphere`/`geography`), а не приближением «градусы × 111320». Гистограмма разброса по 258 домам с >1 различной точкой **обрыва не даёт**: плотное ядро 0-125 м (150 домов), затем ровное плато ~4-5 домов на каждые 25 м вплоть до 400 м, и масса из 67 домов дальше 400 м. Раз в данных порога нет, он взят от **потребителя**: гео-ограждение `house_dedup_merge` (#2187) считает два дома одним зданием при `ST_DistanceSphere <= 250`. Записанный центроид — оценка с погрешностью ровно в разброс; отдать ей весь бюджет ограждения значит позволить двум домам разъехаться на 500 м и сломать сравнение. Половина бюджета → 125 м. Решение не несущее: 125 м пропускает 1382 дома, 250 м — 1403. Разница в 21 дом не теряется, а откладывается до следующего прогона. ## Замер ДО — тем же SQL, что мержится `_BUILD_CANDIDATES_SQL` + `_COUNTERS_SQL` из самого модуля, прогнаны на проде read-only (BEGIN/ROLLBACK): ``` houses_total 9625 | with_coords 7680 | without_coords 1945 (79.8%) candidates 1945 ├─ eligible 1382 → получат координаты ├─ skipped_no_listings 114 объявлений нет вовсе ├─ skipped_no_listing_coords 341 объявления есть, но сами без координат └─ skipped_scattered 108 разброс > 125 м (18 дальше 5 км, худший 289.9 км) ``` Ожидание ПОСЛЕ первого прогона: 7680 → 9062 домов с координатами (79.8% → 94.2%), без координат 1945 → 563. ## Пропуск не молчит Счётчики разделены по причинам (`skipped_no_listings` / `skipped_no_listing_coords` / `skipped_scattered` / `scattered_over_5km` / `worst_spread_m`), а худшие дома с разбросом называются в логе поимённо — иначе усечение читается как «покрыто всё». Разбросанные дома — дефект **сопоставителя**, вынесен отдельной работой: #2777 (108 домов, 2663 объявления, худший «ул. Кирова,4» — 5 объявлений с радиусом 289.9 км). ## Test plan - [x] `tests/tasks/test_backfill_house_coords_from_listings.py` — 8 статических + поведенческий на живом PostGIS: 5 домов, 5 исходов (перенос центроида / **пропуск дома с объявлениями в 11 км** / объявления без geom / нет объявлений / уже есть координаты — не перезаписаны), второй прогон no-op. - [x] Красный на старом коде: тот же файл на `origin/main` → `ImportError: cannot import name 'backfill_house_coords_from_listings'` — механизма нет. - [x] Мутация (проверка, что тест не тавтологичен): убрать `AND c.spread_m <= ...` из UPDATE → дом с объявлениями в 11 км получает середину между двумя городами (56.89) → тест краснеет. - [x] Полный сьют бэкенда на PostGIS 16-3.4 (как в CI): 3983 passed, 1 skipped. - [x] `ruff check` + `ruff format`, `grep -nE ':[a-z_]+::[a-z]'` пуст (psycopg v3). ## Прод-верификация (критерий записан ДО факта) Задача по расписанию. Ближайший штатный прогон — **03:00-04:00 UTC следующих суток после деплоя**; для немедленного смоука `UPDATE scrape_schedules SET next_run_at=now() WHERE source='house_coords_from_listings'` (подхват <= 60 с, планировщик в `tradein-scraper`). **Критерий приёмки:** после первого прогона `SELECT count(*) FROM houses WHERE lat IS NULL` = **563 ± 20** (было 1945), а `scrape_runs` последнего прогона несёт `updated ≈ 1382`, `skipped_scattered = 108`, `errors = 0`. Отдельно: у дома 380628 («ул. Кирова,4») `lat` остаётся **NULL** — он обязан быть пропущен. Порядок деплоя безопасен: миграция 239 сеет `next_run_at` на завтра 03:00 UTC, поэтому старый образ не встретит источник без handler'а. Closes #2771
bot-backend added 1 commit 2026-08-07 08:56:47 +00:00
fix(tradein/houses): вернуть координаты объявлений в дом, когда объявления согласны (#2771)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 10s
CI / changes (pull_request) Successful in 11s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 4m20s
3c80e2cd73
Дом берёт координаты один раз — при создании, из породившего его объявления.
Объявление геокодируется ПОЗЖЕ (geoportal_coords_backfill, geocode_missing_listings)
и пишет в listings; ON CONFLICT у вставки дома обновляет только address. Обратно
координаты не возвращались: на проде 1945 домов из 9625 (20.2%) без координат, при
этом у 1490 из них объявления с координатами уже есть.

Новая ежедневная задача house_coords_from_listings (окно 03:00-04:00 UTC, перед
house_dedup_merge) считает центроид объявлений дома и их разброс — max
ST_DistanceSphere до центроида. Разброс <= 125 м → центроид пишется в дом.

Порог 125 м взят не из данных (гистограмма обрыва не даёт: ядро 0-125 м, дальше
ровное плато до 400 м), а от потребителя координат: это половина 250-метрового
гео-ограждения house_dedup_merge (#2187). Записанный центроид — оценка с
погрешностью ровно в разброс; отдать ей весь бюджет ограждения значит позволить
двум домам разъехаться на 500 м. Цена решения мала: 125 м пропускает 1382 дома,
250 м — 1403.

Дома с бо́льшим разбросом не трогаются и считаются отдельным счётчиком: это дефект
сопоставителя (#2777), а не задача переноса — в одну запись дома сшиты объявления
разных населённых пунктов, худший разброс 289.9 км. Пропуск виден по каждой
причине отдельно (no_listings / no_listing_coords / scattered), худшие дома
называются в логе поимённо.

Идемпотентно: UPDATE только по lat IS NULL, повторный прогон — no-op.

Замер тем же SQL, что мержится (прод, read-only, ROLLBACK):
candidates=1945 eligible=1382 no_listings=114 no_listing_coords=341
scattered=108 (18 дальше 5 км, худший 289.9 км).
bot-backend merged commit de4b2a4ae5 into main 2026-08-07 09:08:07 +00:00
bot-backend deleted branch fix/2771-house-coords-from-listings 2026-08-07 09:08:07 +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#2780
No description provided.