fix(tradein/tasks): городской гейт в ночном бэкфилле координат (#2583)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 13s
CI / changes (pull_request) Successful in 14s
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 3m8s

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 —
основной массив, вероятно, из других источников координат (не только этот таск).
Чистка — отдельный шаг.
This commit is contained in:
bot-backend 2026-07-31 18:38:39 +03:00
parent eb44718677
commit cf35e632db
3 changed files with 122 additions and 7 deletions

View file

@ -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,
)

View file

@ -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.

View file

@ -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 ───────────────────────────────────────────────────────────────