From cf35e632db93ee7541db0f376cf5423309da7084 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Fri, 31 Jul 2026 18:38:39 +0300 Subject: [PATCH] =?UTF-8?q?fix(tradein/tasks):=20=D0=B3=D0=BE=D1=80=D0=BE?= =?UTF-8?q?=D0=B4=D1=81=D0=BA=D0=BE=D0=B9=20=D0=B3=D0=B5=D0=B9=D1=82=20?= =?UTF-8?q?=D0=B2=20=D0=BD=D0=BE=D1=87=D0=BD=D0=BE=D0=BC=20=D0=B1=D1=8D?= =?UTF-8?q?=D0=BA=D1=84=D0=B8=D0=BB=D0=BB=D0=B5=20=D0=BA=D0=BE=D0=BE=D1=80?= =?UTF-8?q?=D0=B4=D0=B8=D0=BD=D0=B0=D1=82=20(#2583)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit backfill_coords_from_geoportal брал все listings с lat IS NULL, парсил street+house и матчил напрямую против EKB-only ekb_geoportal_buildings, минуя geocoder.geocode() и его городской гейт (_names_non_ekb_city). Улица+дом могут буквально совпасть между Екатеринбургом и другим городом области ("проспект Ленина 1" есть и в ЕКБ, и в Нижнем Тагиле) — такие адреса получали екатеринбургские координаты и портили радиусные выборки аналогов на этой улице в ЕКБ, а сами исчезали из выборки своего города. Фикс: _names_non_ekb_city(address) перед вызовом _geoportal_house_match — тот же гейт, что уже используется в geocode(). Прямой вызов geoportal-матчера (а не полноценный geocode()) сохранён намеренно — pure local-DB операция без внешнего HTTP, полноценный geocode() добавил бы Nominatim/Yandex вызов на каждый non-EKB адрес backlog'а (лишняя нагрузка на ограниченный Nominatim, Yandex сейчас 403 — #2585). geo_precision оставлен NULL для house-level матчей этого тира — по конвенции 089_listings_geo_precision.sql/geocode_missing.py NULL означает "не coarse", то же значение что geo_precision=None для precise-адресов в geocode_missing_listings; исключать из radius-аналогов нужно только 'city'-fallback. Порядок окон (05:00 geoportal → 06:00 geocode_missing_listings) не менялся: гонка была безвредна для корректно заматченных EKB-адресов, вредна только из-за отсутствия гейта — теперь non-EKB адреса здесь не матчатся вообще и просто ждут oblast-aware провайдеров в следующем окне. Поправлен ложный комментарий в migration 171 ("не-ЕКБ адреса не матчатся — корректно"). Ущерб на проде (SELECT-only, без изменений): 2040 листингов с координатами внутри EKB-bbox (56.65-56.95, 60.40-60.85) при адресе, называющем другой город области (1941 после исключения мкр/р-н/жк-омонимов вроде ЖК "Заречный" внутри ЕКБ). Только ~31 из них совпадают по координатам с ekb_geoportal_buildings/gendesign_cad_buildings — основной массив, вероятно, из других источников координат (не только этот таск). Чистка — отдельный шаг. --- .../backfill_listings_coords_geoportal.py | 47 ++++++++++-- ...hedules_seed_geoportal_coords_backfill.sql | 10 ++- ...test_backfill_listings_coords_geoportal.py | 72 ++++++++++++++++++- 3 files changed, 122 insertions(+), 7 deletions(-) diff --git a/tradein-mvp/backend/app/tasks/backfill_listings_coords_geoportal.py b/tradein-mvp/backend/app/tasks/backfill_listings_coords_geoportal.py index 035f7be1..f95ae077 100644 --- a/tradein-mvp/backend/app/tasks/backfill_listings_coords_geoportal.py +++ b/tradein-mvp/backend/app/tasks/backfill_listings_coords_geoportal.py @@ -10,6 +10,20 @@ Парсинг адреса — _parse_street_house из app.services.geocoder (готовый парсер), работающий с формами «г. Екатеринбург, ул. Малышева, 30, кв. 28». +Городской гейт (#2583, находка H3): в `listings` НЕТ отдельной колонки города — город +известен только из текста адреса. `ekb_geoportal_buildings` — EKB-only реестр: улица+дом +могут буквально совпасть между Екатеринбургом и другим городом области (например, +«проспект Ленина 1» есть и в ЕКБ, и в Нижнем Тагиле). Без проверки города такой листинг +получает екатеринбургские координаты, хотя находится в другом городе. Перед вызовом +_geoportal_house_match каждый адрес проверяется через _names_non_ekb_city (та же функция, +что гейтит EKB-only тиры внутри geocoder.geocode()) — адрес, явно называющий другой город +региона, пропускается (counted как skipped_non_ekb) и остаётся lat IS NULL для +geocode_missing_listings (oblast-aware Nominatim/Yandex, окно 06:00-09:00 UTC). +Прямой вызов _geoportal_house_match (а не полноценный geocode()) оставлен намеренно — +это pure local-DB матч без единого внешнего HTTP-запроса; полноценный geocode() на каждый +non-EKB адрес добавил бы Nominatim/Yandex вызов на весь backlog (сотни-тысячи строк за +ночь) — лишняя нагрузка на и так ограниченный Nominatim (Yandex сейчас 403, #2585). + Запуск: python -m app.tasks.backfill_listings_coords_geoportal python -m app.tasks.backfill_listings_coords_geoportal --limit 5000 --batch-size 200 @@ -19,7 +33,17 @@ migration 171) — run_geoportal_coords_backfill(). Local exact match, ника HTTP/rate-limit, поэтому окно ставится ПЕРЕД geocode_missing_listings (Nominatim/Yandex, coarse city-centroid fallback): точный house-level матч должен получить шанс первым, иначе Nominatim успевает проставить грубые coords и адрес выпадает из WHERE lat IS NULL -(#1967 — было единичным manual-прогоном #1841, здесь становится recurring). +(#1967 — было единичным manual-прогоном #1841, здесь становится recurring). С городским +гейтом (#2583) порядок окон остаётся корректным: не-ЕКБ адреса больше не матчатся здесь +вообще, поэтому «победа в гонке» больше не портит их координаты — они просто ждут +geocode_missing_listings в следующем окне, как и раньше для адресов без geoportal-матча. + +geo_precision: этот тир всегда даёт house-level точный матч (не city-centroid), поэтому +_update_listing_coords НЕ проставляет geo_precision — он остаётся NULL, что в текущей +конвенции (089_listings_geo_precision.sql, geocode_missing.py) означает «не coarse» +(тот же смысл, что и geo_precision=None для precise-адресов в geocode_missing_listings). +Downstream-фильтры (`geo_precision IS DISTINCT FROM 'city'`) корректно НЕ исключают такие +строки — исключать нужно только 'city'-fallback, а не «пока не размечено». Идемпотентность: UPDATE применяется только к строкам с lat IS NULL (WHERE id=:id AND lat IS NULL). Повторный прогон не затирает уже проставленные координаты. @@ -37,7 +61,7 @@ from sqlalchemy.orm import Session from app.core.db import SessionLocal from app.services import scrape_runs as runs_mod -from app.services.geocoder import _geoportal_house_match, _parse_street_house +from app.services.geocoder import _geoportal_house_match, _names_non_ekb_city, _parse_street_house logger = logging.getLogger(__name__) @@ -55,6 +79,7 @@ class BackfillCoordsResult: updated: int = 0 # реально обновлено (UPDATE rowcount) no_address: int = 0 # listing.address IS NULL / не распарсился no_match: int = 0 # адрес распарсился, но в реестре здания нет + skipped_non_ekb: int = 0 # адрес явно называет другой город области (#2583 гейт) errors: int = 0 # исключения при обработке отдельной записи duration_sec: float = field(default=0.0) @@ -65,6 +90,7 @@ class BackfillCoordsResult: "updated": self.updated, "no_address": self.no_address, "no_match": self.no_match, + "skipped_non_ekb": self.skipped_non_ekb, "errors": self.errors, "duration_sec": int(self.duration_sec), } @@ -184,6 +210,14 @@ def backfill_coords_from_geoportal( res.no_address += 1 continue + # Городской гейт (#2583, H3) — ekb_geoportal_buildings EKB-only, + # улица+дом могут совпасть с другим городом области. Адрес, явно + # называющий другой город региона, пропускаем — остаётся + # lat IS NULL для oblast-aware geocode_missing_listings. + if _names_non_ekb_city(address): + res.skipped_non_ekb += 1 + continue + # Парсинг адреса — переиспользуем парсер geocoder'а parsed = _parse_street_house(address) if parsed is None: @@ -259,12 +293,13 @@ def backfill_coords_from_geoportal( logger.info( "backfill_coords: DONE — candidates=%d matched=%d updated=%d " - "no_address=%d no_match=%d errors=%d duration=%.1fs", + "no_address=%d no_match=%d skipped_non_ekb=%d errors=%d duration=%.1fs", res.candidates, res.matched, res.updated, res.no_address, res.no_match, + res.skipped_non_ekb, res.errors, res.duration_sec, ) @@ -314,13 +349,14 @@ def run_geoportal_coords_backfill( runs_mod.mark_done(db, run_id, counters) logger.info( "run_geoportal_coords_backfill: run_id=%d DONE candidates=%d matched=%d " - "updated=%d no_address=%d no_match=%d errors=%d duration=%.1fs", + "updated=%d no_address=%d no_match=%d skipped_non_ekb=%d errors=%d duration=%.1fs", run_id, res.candidates, res.matched, res.updated, res.no_address, res.no_match, + res.skipped_non_ekb, res.errors, res.duration_sec, ) @@ -376,12 +412,13 @@ def main() -> None: logger.info( "Готово: кандидатов=%d сматчено=%d обновлено=%d " - "без_адреса=%d без_матча=%d ошибок=%d время=%.1fs", + "без_адреса=%d без_матча=%d не_ЕКБ=%d ошибок=%d время=%.1fs", result.candidates, result.matched, result.updated, result.no_address, result.no_match, + result.skipped_non_ekb, result.errors, result.duration_sec, ) diff --git a/tradein-mvp/backend/data/sql/171_scrape_schedules_seed_geoportal_coords_backfill.sql b/tradein-mvp/backend/data/sql/171_scrape_schedules_seed_geoportal_coords_backfill.sql index c4492400..4be5641c 100644 --- a/tradein-mvp/backend/data/sql/171_scrape_schedules_seed_geoportal_coords_backfill.sql +++ b/tradein-mvp/backend/data/sql/171_scrape_schedules_seed_geoportal_coords_backfill.sql @@ -13,7 +13,15 @@ -- precision БЕЗ единого внешнего HTTP-запроса (в отличие от Nominatim) — но был ТОЛЬКО -- manual script (`python -m app.tasks.backfill_listings_coords_geoportal`), ни разу не -- запускавшийся на recurring основе. Один прошлый ручной прогон (#1841): 17241 --- кандидатов → 1008 проставлено (не-ЕКБ адреса не матчатся — корректно, EKB-only реестр). +-- кандидатов → 1008 проставлено. +-- +-- ИСПРАВЛЕНО #2583 (находка H3): до фикса не-ЕКБ адреса region 66 (Нижний Тагил, Серов +-- и т.д.) НЕ отсекались — street+house парсились без учёта города и слепо матчились +-- против EKB-only реестра. Улица+дом могут буквально совпасть с ЕКБ ("проспект Ленина 1" +-- есть и в ЕКБ, и в Нижнем Тагиле) — такой листинг получал координаты Екатеринбурга. +-- Фикс: городской гейт _names_non_ekb_city перед вызовом _geoportal_house_match (тот же +-- гейт, что и в geocoder.geocode()). Не "корректно, EKB-only реестр", как было написано +-- здесь раньше — это была реальная утечка не-ЕКБ адресов в ЕКБ-координаты. -- -- Решение: wire в in-app scheduler (source='geoportal_coords_backfill') по паттерну -- cadastral_geo_match (migration 125) — pure internal DB op, SAFE to enable=true. diff --git a/tradein-mvp/backend/tests/tasks/test_backfill_listings_coords_geoportal.py b/tradein-mvp/backend/tests/tasks/test_backfill_listings_coords_geoportal.py index c2db0780..23be177c 100644 --- a/tradein-mvp/backend/tests/tasks/test_backfill_listings_coords_geoportal.py +++ b/tradein-mvp/backend/tests/tasks/test_backfill_listings_coords_geoportal.py @@ -30,7 +30,7 @@ os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost: _wp_mock = MagicMock() sys.modules.setdefault("weasyprint", _wp_mock) -from app.services.geocoder import GeocodeSuggestion # noqa: E402 +from app.services.geocoder import GeocodeSuggestion, _names_non_ekb_city # noqa: E402 from app.tasks import backfill_listings_coords_geoportal as bcg # noqa: E402 from app.tasks.backfill_listings_coords_geoportal import ( # noqa: E402 BackfillCoordsResult, @@ -203,6 +203,76 @@ def test_unparseable_address_counted_as_no_address() -> None: mock_geo.assert_not_called() +# ── городской гейт (#2583, H3) ───────────────────────────────────────────────── + + +def test_non_ekb_address_skipped_not_matched_against_ekb_registry() -> None: + """Адрес другого города области (Нижний Тагил) НЕ должен матчиться против + EKB-only ekb_geoportal_buildings, даже если street+house буквально совпадают + с ЕКБ-домом («проспект Ленина 1» есть и в ЕКБ, и в Нижнем Тагиле, #2583 H3). + + _names_non_ekb_city — РЕАЛЬНАЯ функция (не мок), чтобы проверить настоящий гейт, + а не заглушку. _geoportal_house_match мокнут так, будто он ложно нашёл ЕКБ-дом — + тест доказывает, что до этого вызова дело не доходит вообще. + """ + rows = [{"id": 100, "address": "г. Нижний Тагил, проспект Ленина, 1"}] + db = _make_db([rows, []]) + + # Sanity: реальная функция действительно считает этот адрес не-ЕКБ. + assert _names_non_ekb_city("г. Нижний Тагил, проспект Ленина, 1") is True + + with ( + patch( + "app.tasks.backfill_listings_coords_geoportal._geoportal_house_match", + return_value=_HIT, # ложное совпадение по street+house, если бы гейт не сработал + ) as mock_geo, + patch("app.tasks.backfill_listings_coords_geoportal._parse_street_house") as mock_parse, + ): + res = backfill_coords_from_geoportal(db, batch_size=500) + + assert res.candidates == 1 + assert res.skipped_non_ekb == 1 + assert res.matched == 0 + assert res.updated == 0 + assert res.no_match == 0 + assert res.no_address == 0 + # Ни парсер, ни geoportal-матчер не должны были вызываться — гейт стоит раньше. + mock_parse.assert_not_called() + mock_geo.assert_not_called() + update_calls = [c for c in db.execute.call_args_list if "UPDATE" in str(c.args[0])] + assert len(update_calls) == 0 + + +def test_ekb_address_still_matched_with_real_gate() -> None: + """Явно ЕКБ-адрес по-прежнему проходит гейт и матчится как раньше (не деградация). + + _names_non_ekb_city — реальная функция: "Екатеринбург" в адресе имеет приоритет + над любым омонимом другого города области (см. docstring _names_non_ekb_city). + """ + rows = [{"id": 101, "address": "г. Екатеринбург, проспект Ленина, 1"}] + db = _make_db([rows, []]) + + assert _names_non_ekb_city("г. Екатеринбург, проспект Ленина, 1") is False + + with ( + patch( + "app.tasks.backfill_listings_coords_geoportal._geoportal_house_match", + return_value=_HIT, + ) as mock_geo, + patch( + "app.tasks.backfill_listings_coords_geoportal._parse_street_house", + return_value=("проспект ленина", "1"), + ), + ): + res = backfill_coords_from_geoportal(db, batch_size=500) + + assert res.candidates == 1 + assert res.skipped_non_ekb == 0 + assert res.matched == 1 + assert res.updated == 1 + mock_geo.assert_called_once_with(db, "проспект ленина", "1") + + # ── idempotency ─────────────────────────────────────────────────────────────── -- 2.45.3