From 9898b6bc029a9e80273dce02ad9d727ada352be4 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Wed, 9 Sep 2026 02:51:18 +0300 Subject: [PATCH] =?UTF-8?q?feat(tradein/geocoder):=20=D1=80=D0=B5=D0=B3?= =?UTF-8?q?=D0=B8=D0=BE=D0=BD-=D0=BF=D0=B0=D1=80=D0=B0=D0=BC=D0=B5=D1=82?= =?UTF-8?q?=D1=80=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D1=8F=20=D0=B3=D0=B5=D0=BE?= =?UTF-8?q?=D0=BA=D0=BE=D0=B4=D0=B5=D1=80=D0=B0=20=E2=80=94=20region=5Fcod?= =?UTF-8?q?e=20=D0=B2=20geocode()/known=5Fcity=5Fhint,=20--region-code=20?= =?UTF-8?q?=D1=83=20=D1=81=D0=BA=D1=80=D0=B8=D0=BF=D1=82=D0=B0=20=D1=81?= =?UTF-8?q?=D0=B4=D0=B5=D0=BB=D0=BE=D0=BA,=20region=5Fcode=20=D1=83=20admi?= =?UTF-8?q?n=20geocode-missing=20(#3051)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Геокодер был жёстко привязан к Свердловской области: viewbox 66 + bounded=1, accept только при state ~ 'свердловск' и точке в bbox 66, known_city_hint знал лишь города области → для 212 937 московских сделок (address 'Москва, <улица>') геокод давал None либо ложный хит по одноимённой улице области, а cache-ключ без города смешивал регионы. Теперь регион приходит от вызывающего (deals.region_code): viewbox и bbox из REGIONS[code], state-маркер per region ('свердловск'/'москва'), ЕКБ-тиры (geoportal/cadastral/local houses) только при 66, city-хинт через словарь региона → cache-ключ '|city=москва'. Дефолт 66 везде — для существующих вызовов поведение байт-идентично (ревью двумя линзами). Побочно: geocode-missing по умолчанию больше не берёт listings с region_code NULL (16 930 неактивных чужих городов, которые и раньше геокодились впустую). --- tradein-mvp/backend/app/api/v1/admin.py | 16 +- tradein-mvp/backend/app/services/geocoder.py | 212 +++++++++++++----- .../scripts/geocode_deals_nominatim.py | 108 ++++++--- .../scripts/test_geocode_deals_nominatim.py | 7 +- .../tests/tasks/test_geocode_missing.py | 4 +- .../backend/tests/test_geocoder_city_hint.py | 2 +- 6 files changed, 256 insertions(+), 93 deletions(-) diff --git a/tradein-mvp/backend/app/api/v1/admin.py b/tradein-mvp/backend/app/api/v1/admin.py index 32b8c47f..aa3abd64 100644 --- a/tradein-mvp/backend/app/api/v1/admin.py +++ b/tradein-mvp/backend/app/api/v1/admin.py @@ -268,6 +268,7 @@ async def geocode_missing( db: Annotated[Session, Depends(get_db)], limit: int = 100, target: Literal["listings", "deals"] = "listings", + region_code: int = 66, ) -> dict: """Геокодинг listings ИЛИ deals у которых нет lat/lon (используя address). @@ -279,6 +280,10 @@ async def geocode_missing( geocode_tried_at: после КАЖДОЙ попытки (успех/провал) ставим NOW(). Failed- адреса не выбираются повторно 7 дней → cron-loop завершается, не зацикливается. geom обновляется автоматически триггером. + + region_code (дефолт 66, #3051) — фильтрует обе таблицы (`listings`/`deals` + несут колонку) и прокидывается в `known_city_hint`/`geocode`. Дефолт 66 — + прежнее поведение без изменений (cron не меняется, follow-up ниже). """ # Доп. фильтр для listings — у Avito встречаются плейсхолдер-адреса. extra_filter = "AND address NOT LIKE '%(Avito)%'" if target == "listings" else "" @@ -291,13 +296,14 @@ async def geocode_missing( WHERE lat IS NULL AND COALESCE(address, '') != '' {extra_filter} + AND region_code = CAST(:region_code AS int) AND (geocode_tried_at IS NULL OR geocode_tried_at < NOW() - interval '7 days') ORDER BY geocode_tried_at NULLS FIRST LIMIT :limit """ ), - {"limit": limit}, + {"limit": limit, "region_code": region_code}, ) .mappings() .all() @@ -332,8 +338,8 @@ async def geocode_missing( # хинт закрывает EKB-локальные тиры и уезжает префиксом в запрос # провайдеру, т.е. вреднее отсутствия хинта. Общий хелпер, тот же, что у # scripts/geocode_deals_nominatim.py и tasks/geocode_missing.py. - city = known_city_hint(row.get("city")) - result = await geocode(clean, db, city_hint=city) + city = known_city_hint(row.get("city"), region_code) + result = await geocode(clean, db, city_hint=city, region_code=region_code) if result is None: # Помечаем что пробовали — иначе ретрай на каждом cron. db.execute( @@ -362,10 +368,12 @@ async def geocode_missing( WHERE lat IS NULL AND COALESCE(address, '') != '' {extra_filter} + AND region_code = CAST(:region_code AS int) AND (geocode_tried_at IS NULL OR geocode_tried_at < NOW() - interval '7 days') """ - ) + ), + {"region_code": region_code}, ).scalar() return { diff --git a/tradein-mvp/backend/app/services/geocoder.py b/tradein-mvp/backend/app/services/geocoder.py index f7760ce8..b882558c 100644 --- a/tradein-mvp/backend/app/services/geocoder.py +++ b/tradein-mvp/backend/app/services/geocoder.py @@ -28,9 +28,16 @@ from tenacity import retry, stop_after_attempt, wait_exponential from app.core.config import settings from app.services import dadata from app.services.regions import REGIONS as _ALL_REGIONS +from app.services.regions import Region, is_within_bbox _REGION_66 = _ALL_REGIONS[66] +# #3051: маркер `address.state` Nominatim по региону, для region cross-check +# в `_nominatim_region_ok` (см. использование в `_nominatim_query`). Регионы +# без записи здесь получают `marker=None` → cross-check пропускается +# (fallback на bbox-only, прежнее поведение). +_REGION_STATE_MARKERS: dict[int, str] = {66: "свердловск", 77: "москва"} + logger = logging.getLogger(__name__) # ── Общий ограничитель темпа обращений к Nominatim (#2953) ────────────────── @@ -170,8 +177,15 @@ def is_within_oblast66_bbox(lat: float, lon: float) -> bool: SVERDLOVSK_OBLAST_CITIES = _REGION_66.cities # #3051: список — в реестре регионов -def known_city_hint(value: str | None) -> str | None: - """`value` как city_hint, если это узнаваемое имя города региона 66, иначе None. +def known_city_hint(value: str | None, region_code: int = 66) -> str | None: + """`value` как city_hint, если это узнаваемое имя города `REGIONS[region_code]`, иначе None. + + #3051: `region_code` (дефолт 66) — параметризация под трек «Москва»: словарь + городов берётся из `REGIONS[region_code].cities`, а не жёстко из + `SVERDLOVSK_OBLAST_CITIES`. Для `region_code=66` (дефолт, все существующие + вызовы без аргумента) — byte-identical прежнему поведению: `REGIONS[66].cities + is SVERDLOVSK_OBLAST_CITIES` (тот же frozenset-объект, см. модульный уровень). + Неизвестный `region_code` → ValueError (явная ошибка, не молчаливый None). Для callers, которые берут город из КОЛОНКИ БД и передают его в `geocode()` (#2603): `deals.city` — росреестровое поле, заполнено на 100%, но в хвосте @@ -200,7 +214,14 @@ def known_city_hint(value: str | None) -> str | None: """ if not value: return None - return value if " ".join(value.lower().split()) in SVERDLOVSK_OBLAST_CITIES else None + if region_code == 66: + cities = SVERDLOVSK_OBLAST_CITIES + else: + try: + cities = _ALL_REGIONS[region_code].cities + except KeyError as exc: + raise ValueError(f"unknown region_code={region_code!r}") from exc + return value if " ".join(value.lower().split()) in cities else None # Значение для DaData-констрейнта `locations: [{"region": ...}]`. @@ -231,20 +252,41 @@ _OBLAST_MARKER_RE = re.compile(r"\bсвердловск\w*\b") _DISTRICT_PREFIXES = frozenset({"мкр", "мкр.", "микрорайон", "р-н", "р-он", "район", "жк"}) -def _has_oblast_marker(text_lower: str) -> bool: - """True если текст уже содержит упоминание области/города региона 66. +# region_code → скомпилированный regex городов региона (word-boundary), кэш по +# коду. 66 — literal reuse `_OBLAST_CITY_RE` (тот же объект, byte-identical), +# остальные регионы строятся из `REGIONS[region_code].cities` при первом +# обращении (#3051). +_REGION_CITY_RE: dict[int, re.Pattern[str]] = {66: _OBLAST_CITY_RE} - Используется чтобы НЕ навязывать "Екатеринбург, " в запрос, когда адрес - уже привязан к другому городу/области — иначе получим двойной город - ("Екатеринбург, Нижний Тагил, Ленина 10") и провайдер вернёт мусор/пусто. - Матчинг — по границе слова/фразы (см. `_OBLAST_CITY_RE`), НЕ substring — - и с исключением "мкр/микрорайон/р-н <город>" (район ВНУТРИ другого города). +def _region_city_re(region_code: int) -> re.Pattern[str]: + cached = _REGION_CITY_RE.get(region_code) + if cached is not None: + return cached + cities = _ALL_REGIONS[region_code].cities + compiled = re.compile(r"\b(?:" + "|".join(re.escape(c) for c in cities) + r")\b") + _REGION_CITY_RE[region_code] = compiled + return compiled + + +def _has_oblast_marker(text_lower: str, region_code: int = 66) -> bool: + """True если текст уже содержит упоминание области/города `region_code`. + + Используется чтобы НЕ навязывать "Екатеринбург, "/"Москва, " в запрос, + когда адрес уже привязан к другому городу/области — иначе получим двойной + город ("Екатеринбург, Нижний Тагил, Ленина 10" / "Москва, Москва, Тверская + 1", #3051 п. б) и провайдер вернёт мусор/пусто. + + Матчинг — по границе слова/фразы (`_region_city_re`), НЕ substring — и с + исключением "мкр/микрорайон/р-н <город>" (район ВНУТРИ другого города). + `region_code=66` (дефолт) дополнительно матчит "свердловск*" — областной + маркер без города; у прочих регионов такого обобщённого маркера нет, + город региона уже покрывает случай (для 77 — "москва" в `region.cities`). """ normalized = " ".join(text_lower.split()) - if _OBLAST_MARKER_RE.search(normalized): + if region_code == 66 and _OBLAST_MARKER_RE.search(normalized): return True - for m in _OBLAST_CITY_RE.finditer(normalized): + for m in _region_city_re(region_code).finditer(normalized): prefix_words = normalized[: m.start()].split() if prefix_words and prefix_words[-1] in _DISTRICT_PREFIXES: continue # «мкр Заречный» — район, не город-ЗАТО Заречный @@ -252,12 +294,14 @@ def _has_oblast_marker(text_lower: str) -> bool: return False -def _resolve_city_for_geocode(address: str, city_hint: str | None) -> tuple[str | None, bool]: +def _resolve_city_for_geocode( + address: str, city_hint: str | None, region_code: int = 66 +) -> tuple[str | None, bool]: """Определяет, какой город подставлять в запрос внешнему провайдеру (Nominatim), когда сам текст адреса города не называет. Приоритет: - 1. Адрес уже содержит маркер города/области региона 66 (`_has_oblast_marker`) + 1. Адрес уже содержит маркер города/области `region_code` (`_has_oblast_marker`) → город уже указан пользователем в тексте адреса, ничего подставлять не нужно. Возвращает (None, True). 2. `city_hint` передан вызывающим кодом (например, фронт знает выбранный @@ -266,15 +310,18 @@ def _resolve_city_for_geocode(address: str, city_hint: str | None) -> tuple[str — для жителей других городов области это давало уверенно неверную цену («Ленина, 1» в Нижнем Тагиле снапалось на екатеринбургскую улицу Ленина, обе улицы называются одинаково). Теперь НЕ подставляем никакой город — - провайдер ищет по OBLAST66 viewbox/bbox (см. `OBLAST66_VIEWBOX`), без - привязки к конкретному городу. Возвращает + провайдер ищет по region-viewbox/bbox (см. `OBLAST66_VIEWBOX`, + `_region_viewbox`), без привязки к конкретному городу. Возвращает (None, False) — второй элемент False сигнализирует, что город пользователь НЕ указывал (источник `GeocodeResult.city_ambiguous`). + `region_code` (дефолт 66, #3051) — byte-identical прежнему поведению для + всех вызовов без аргумента. + Returns: (city_or_none, city_specified_by_user). """ - if _has_oblast_marker(address.lower()): + if _has_oblast_marker(address.lower(), region_code): return None, True hint = (city_hint or "").strip() if hint: @@ -678,13 +725,15 @@ def _cache_put(db: Session, address_norm: str, result: GeocodeResult) -> None: # ── Provider: Nominatim (OSM, без ключа) ──────────────────────────────────── -def _nominatim_region_ok(item: dict) -> bool | None: +def _nominatim_region_ok(item: dict, region_code: int = 66) -> bool | None: """Кросс-чек региона по Nominatim `address.state` (доступно т.к. addressdetails=1). - True/False если state однозначно про/не про Свердловскую область. None если - поле отсутствует/не строка — тогда accept-логика падает обратно на bbox. + True/False если state однозначно про/не про регион `region_code`. None если + поле отсутствует/не строка, ИЛИ регион не имеет записи в `_REGION_STATE_MARKERS` + — тогда accept-логика падает обратно на bbox. Ловит Тюмень/Шадринск/Кунгур/Снежинск — они внутри генерального OBLAST66_BBOX - (специально щедрого), но их state явно другой регион. + (специально щедрого), но их state явно другой регион. `region_code=66` + (дефолт) — byte-identical прежнему поведению (`"свердловск" in state.lower()`). """ addr = item.get("address") if not isinstance(addr, dict): @@ -692,22 +741,41 @@ def _nominatim_region_ok(item: dict) -> bool | None: state = addr.get("state") if not isinstance(state, str) or not state: return None - return "свердловск" in state.lower() + marker = _REGION_STATE_MARKERS.get(region_code) + if marker is None: + return None + return marker in state.lower() -async def _nominatim_query(client: httpx.AsyncClient, address: str) -> dict | None: +def _region_viewbox(region: Region) -> str: + """Nominatim `viewbox` (lon_min,lat_max,lon_max,lat_min) из `region.bbox_region`. + + Для region 66 см. `OBLAST66_VIEWBOX["viewbox"]` — литеральная константа + (byte-identical), эта функция для 66 не вызывается. + """ + lat_min, lat_max, lon_min, lon_max = region.bbox_region + return f"{lon_min},{lat_max},{lon_max},{lat_min}" + + +async def _nominatim_query( + client: httpx.AsyncClient, address: str, region_code: int = 66 +) -> dict | None: """Single Nominatim search. Возвращает лучший item или None. - ВАЖНО: фильтруем результаты по bbox области (region 66) прямо тут, чтобы при - опечатках не возвращать Пермский край / Челябинск — но не резать легитимные - Нижний Тагил / Серов и т.д. (генеральный bbox всей Свердловской области). + ВАЖНО: фильтруем результаты по bbox региона `region_code` прямо тут, чтобы + при опечатках не возвращать Пермский край / Челябинск — но не резать + легитимные Нижний Тагил / Серов и т.д. (генеральный bbox всего региона). Two-pass tie-break: среди кандидатов предпочитаем того, кто попадает в TIGHT - ЕКБ-bbox (byte-identical для ЕКБ-запросов, даже если Nominatim ранжировал его - не первым) — иначе первый кандидат внутри OBLAST66. Плюс region cross-check - (`address.state`) — отсекает кандидатов ЯВНО из другого региона (Тюмень и - т.п.), даже если координаты попали в генеральный bbox. + bbox региона (byte-identical для region_code=66, даже если Nominatim + ранжировал его не первым) — иначе первый кандидат внутри генерального bbox + региона. Плюс region cross-check (`address.state`) — отсекает кандидатов + ЯВНО из другого региона (Тюмень и т.п.), даже если координаты попали в + генеральный bbox. `region_code=66` (дефолт) — byte-identical прежнему + поведению (те же bbox-значения и та же viewbox-строка). """ + region = _ALL_REGIONS[region_code] + viewbox = OBLAST66_VIEWBOX["viewbox"] if region_code == 66 else _region_viewbox(region) await _nominatim_throttle() response = await client.get( "https://nominatim.openstreetmap.org/search", @@ -717,8 +785,8 @@ async def _nominatim_query(client: httpx.AsyncClient, address: str) -> dict | No "limit": "3", "countrycodes": "ru", "addressdetails": "1", - "viewbox": OBLAST66_VIEWBOX["viewbox"], - "bounded": "1", # строго в пределах области (region 66) + "viewbox": viewbox, + "bounded": "1", # строго в пределах региона }, ) response.raise_for_status() @@ -730,11 +798,11 @@ async def _nominatim_query(client: httpx.AsyncClient, address: str) -> dict | No lon_f = float(item["lon"]) except Exception: continue - if _nominatim_region_ok(item) is False: - continue # регион явно не Свердловская область — не рассматриваем - if is_within_ekb_bbox(lat_f, lon_f): - return item # tight-ЕКБ приоритетнее — тот же результат, что и раньше - if oblast_fallback is None and is_within_oblast66_bbox(lat_f, lon_f): + if _nominatim_region_ok(item, region_code) is False: + continue # регион явно не тот, что запрошен — не рассматриваем + if is_within_bbox(lat_f, lon_f, region.bbox_tight): + return item # tight-bbox приоритетнее — тот же результат, что и раньше + if oblast_fallback is None and is_within_bbox(lat_f, lon_f, region.bbox_region): oblast_fallback = item return oblast_fallback @@ -756,21 +824,25 @@ async def _nominatim_query(client: httpx.AsyncClient, address: str) -> dict | No # если голый tenacity.RetryError (не httpx-исключение) всплывёт откуда-то ещё # (belt-and-suspenders для retry-кода без reraise=True, напр. scraper_kit). @retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=8), reraise=True) -async def _nominatim_lookup(address: str, city_hint: str | None = None) -> GeocodeResult | None: +async def _nominatim_lookup( + address: str, city_hint: str | None = None, region_code: int = 66 +) -> GeocodeResult | None: """OSM Nominatim — бесплатно, без ключа, 1 req/sec policy. Бан-policy: User-Agent с email обязателен. - Tier 1: bounded область (region 66) на оригинальный адрес. - Tier 2: bounded область (region 66) на typo-варианты (Цвилинга → Цвиллинга). + Tier 1: bounded регион `region_code` на оригинальный адрес. + Tier 2: bounded регион `region_code` на typo-варианты (Цвилинга → Цвиллинга). #2580 (C): city_hint, если известен, подставляется в текст запроса — без - него `_nominatim_query` полагается ТОЛЬКО на oblast66-bbox фильтр + tie-break - (предпочитает tight-ЕКБ bbox), который для одноимённых улиц ВНУТРИ региона - (напр. "Ленина" — и в Екатеринбурге, и в с. Свердловское) не различает город. - Эмпирически подтверждено: "Ленина 1" без города → случайное село внутри - области; "Нижний Тагил, Ленина 1" → корректно резолвится. Nominatim — - единственный живой внешний провайдер (#2593: Yandex Geocoder удалён) — - city_hint должен реально влиять на его результат, не только на кэш-ключ. + него `_nominatim_query` полагается ТОЛЬКО на region-bbox фильтр + tie-break + (предпочитает tight bbox региона), который для одноимённых улиц ВНУТРИ + региона (напр. "Ленина" — и в Екатеринбурге, и в с. Свердловское) не + различает город. Эмпирически подтверждено: "Ленина 1" без города → + случайное село внутри области; "Нижний Тагил, Ленина 1" → корректно + резолвится. Nominatim — единственный живой внешний провайдер (#2593: + Yandex Geocoder удалён) — city_hint должен реально влиять на его результат, + не только на кэш-ключ. `region_code=66` (дефолт, #3051) — byte-identical + прежнему поведению для всех вызовов без аргумента. """ headers = { "User-Agent": f"TradeInMVP/0.1 (contact: {settings.contact_email})", @@ -778,18 +850,18 @@ async def _nominatim_lookup(address: str, city_hint: str | None = None) -> Geoco "Accept-Language": "ru,en;q=0.8", "Referer": "https://tradein-mvp.local/", } - city, _ = _resolve_city_for_geocode(address, city_hint) + city, _ = _resolve_city_for_geocode(address, city_hint, region_code) query = f"{city}, {address}" if city else address async with httpx.AsyncClient(timeout=10.0, headers=headers) as client: # Tier 1: оригинал - item = await _nominatim_query(client, query) + item = await _nominatim_query(client, query, region_code) # Tier 2: typo-variants if item is None: for variant in _typo_variants(address, limit=4): - variant_city, _ = _resolve_city_for_geocode(variant, city_hint) + variant_city, _ = _resolve_city_for_geocode(variant, city_hint, region_code) variant_query = f"{variant_city}, {variant}" if variant_city else variant - item = await _nominatim_query(client, variant_query) + item = await _nominatim_query(client, variant_query, region_code) if item is not None: logger.info("nominatim typo-fixed: %s → %s", address, variant) break @@ -1753,7 +1825,9 @@ async def suggest( # ── Public API ─────────────────────────────────────────────────────────────── -async def geocode(address: str, db: Session, city_hint: str | None = None) -> GeocodeResult | None: +async def geocode( + address: str, db: Session, city_hint: str | None = None, region_code: int = 66 +) -> GeocodeResult | None: """Геокодинг с кэшем + постфактум-проверка подмены города (#2590). Тонкая обёртка над `_geocode_resolve` (вся тировая цепочка там). Инвариант @@ -1771,8 +1845,21 @@ async def geocode(address: str, db: Session, city_hint: str | None = None) -> Ge То есть объявление, уехавшее координатами в чужой город, перестаёт тянуть за собой чужие оценки. Координаты НЕ выбрасываются — деградация честная и видимая, а не отказ. + + `region_code` (дефолт 66, #3051) — какой `REGIONS`-регион искать (bbox, + city-словарь, ЕКБ-only локальные тиры). Неизвестный код → `ValueError` + сразу, а не глубоко внутри `_nominatim_query`. Все существующие вызовы без + аргумента получают region_code=66 — byte-identical прежнему поведению. + `_city_substituted` region_code не принимает: инвариант завязан на + ЕКБ-bbox координат результата (`is_within_ekb_bbox`), который для другого + региона (Москва и т.п.) структурно не совпадает — условие 3 инварианта + никогда не сработает, ложного понижения confidence до "locality" не будет. """ - result = await _geocode_resolve(address, db, city_hint) + try: + _ALL_REGIONS[region_code] + except KeyError as exc: + raise ValueError(f"geocode: unknown region_code={region_code!r}") from exc + result = await _geocode_resolve(address, db, city_hint, region_code) if result is None or not _city_substituted(address, result): return result logger.warning( @@ -1788,7 +1875,7 @@ async def geocode(address: str, db: Session, city_hint: str | None = None) -> Ge async def _geocode_resolve( - address: str, db: Session, city_hint: str | None = None + address: str, db: Session, city_hint: str | None = None, region_code: int = 66 ) -> GeocodeResult | None: """Геокодинг с кэшем. Cadastral FDW → Nominatim → None. @@ -1802,6 +1889,10 @@ async def _geocode_resolve( участвует в cache-ключе (см. `_cache_key`), чтобы ответы для разных городов по одному и тому же тексту адреса не перезатирали друг друга. + region_code: регион покрытия (дефолт 66, #3051). ЕКБ-only локальные + тиры (geoportal/cad_buildings/houses) применяются ТОЛЬКО при 66 — + это ЕКБ-специфичные реестры, у других регионов данных в них нет. + Прокидывается в Nominatim-тир (bbox/viewbox/city-словарь). Returns: GeocodeResult или None если ни один провайдер не отвечает. @@ -1812,7 +1903,7 @@ async def _geocode_resolve( if not address or len(address.strip()) < 3: return None - _, city_specified = _resolve_city_for_geocode(address, city_hint) + _, city_specified = _resolve_city_for_geocode(address, city_hint, region_code) city_ambiguous = not city_specified addr_norm = _cache_key(normalize_address(address), city_hint) @@ -1838,7 +1929,14 @@ async def _geocode_resolve( # Раньше решение по тексту адреса принималось от противного (список из 37 # городов — «нет в списке → считаем ЕКБ»), из-за чего любой другой регион # РФ (Ялта, Трёхгорный) молча резолвился в координаты ЕКБ (#2582). - use_local_ekb = _ekb_local_tiers_allowed(address, city_hint) + # + # #3051: `region_code != 66` закрывает эти тиры целиком, ДО вызова + # `_ekb_local_tiers_allowed` — geoportal/cad_buildings/houses физически не + # содержат данных других регионов (не "город не распознан словарём 66", а + # "реестра для этого региона нет вовсе"), а сама `_ekb_local_tiers_allowed` + # (её ЕКБ-словари: `_names_non_ekb_city`/`_names_unrecognized_locality`) + # region_code не принимает — умышленно не трогаем её сигнатуру. + use_local_ekb = region_code == 66 and _ekb_local_tiers_allowed(address, city_hint) # 2a. Геопортал ЕКБ — ПЕРВЫЙ локальный tier (полнее cad_buildings ~на 70%). if use_local_ekb and parsed is not None: @@ -1914,7 +2012,7 @@ async def _geocode_resolve( # 3. Nominatim fallback try: - result = await _nominatim_lookup(address, city_hint) + result = await _nominatim_lookup(address, city_hint, region_code) if result is not None: result = replace(result, city_ambiguous=city_ambiguous) await asyncio.to_thread(_cache_put, db, addr_norm, result) diff --git a/tradein-mvp/backend/scripts/geocode_deals_nominatim.py b/tradein-mvp/backend/scripts/geocode_deals_nominatim.py index 8bdeda9a..a3cdf3da 100644 --- a/tradein-mvp/backend/scripts/geocode_deals_nominatim.py +++ b/tradein-mvp/backend/scripts/geocode_deals_nominatim.py @@ -80,6 +80,12 @@ Flags: plus the geocode hit/miss split. No DB writes. --batch LABEL log label (default `deals_nominatim_YYYY-MM-DD`). --stale-days N retry addresses last tried more than N days ago (default 30). + --region-code N which `deals.region_code` to geocode (default 66, Sverdlovsk + oblast). #3051: 77 (Москва) — every deal there has + address='Москва, ' with no city already recognised by + `known_city_hint`'s region-66 default, so this flag threads + through both the SQL filter (`AND region_code = N`) and + `geocoder.{geocode,known_city_hint}(..., region_code=N)`. """ from __future__ import annotations @@ -182,7 +188,9 @@ class Stats: # --------------------------------------------------------------------------- -def _select_pending_addresses(db: Session, *, limit: int, stale_days: int) -> list[AddressGroup]: +def _select_pending_addresses( + db: Session, *, limit: int, stale_days: int, region_code: int = 66 +) -> list[AddressGroup]: """Distinct (address, city) pairs still needing coords — resume-safe set. Combines the `deals_geocode_pending_idx` partial index predicate @@ -215,6 +223,7 @@ def _select_pending_addresses(db: Session, *, limit: int, stale_days: int) -> li " WHERE lat IS NULL " " AND address IS NOT NULL " " AND length(trim(address)) >= 3 " + " AND region_code = CAST(:region_code AS int) " " AND (geocode_tried_at IS NULL " " OR geocode_tried_at < NOW() " " - make_interval(days => CAST(:stale_days AS int))) " @@ -223,7 +232,7 @@ def _select_pending_addresses(db: Session, *, limit: int, stale_days: int) -> li "WHERE running_rows - deals_count < CAST(:limit AS int) " "ORDER BY deals_count DESC, address ASC, city ASC NULLS FIRST" ), - {"limit": limit, "stale_days": stale_days}, + {"limit": limit, "stale_days": stale_days, "region_code": region_code}, ) .mappings() .all() @@ -238,7 +247,7 @@ def _select_pending_addresses(db: Session, *, limit: int, stale_days: int) -> li ] -def _count_pending_total(db: Session, *, stale_days: int) -> tuple[int, int]: +def _count_pending_total(db: Session, *, stale_days: int, region_code: int = 66) -> tuple[int, int]: """Full backlog: (distinct (address, city) pairs, total rows) this pass. Denominators for the dry-run projection — counts every lat-IS-NULL deal @@ -257,11 +266,12 @@ def _count_pending_total(db: Session, *, stale_days: int) -> tuple[int, int]: "WHERE lat IS NULL " " AND address IS NOT NULL " " AND length(trim(address)) >= 3 " + " AND region_code = CAST(:region_code AS int) " " AND (geocode_tried_at IS NULL " " OR geocode_tried_at < NOW() " " - make_interval(days => CAST(:stale_days AS int)))" ), - {"stale_days": stale_days}, + {"stale_days": stale_days, "region_code": region_code}, ).first() if row is None: return (0, 0) @@ -274,9 +284,15 @@ def _count_pending_total(db: Session, *, stale_days: int) -> tuple[int, int]: def _update_deals_geocoded( - db: Session, *, address: str, city: str | None, lat: float, lon: float + db: Session, + *, + address: str, + city: str | None, + lat: float, + lon: float, + region_code: int = 66, ) -> int: - """UPDATE every lat-IS-NULL deal on (address, city); geom via trigger. + """UPDATE every lat-IS-NULL deal on (address, city, region_code); geom via trigger. The `deals_set_geom_trg` BEFORE UPDATE OF lat, lon trigger (002_core_tables.sql, reuses listings_set_geom()) populates geom from the @@ -290,6 +306,12 @@ def _update_deals_geocoded( FROM` treats NULL=NULL as a match while staying strict for a real city, so the same street text in another city keeps its own coords (#2603). + `region_code` (#3051) — an extra guard on top of (address, city): the same + street text can legitimately exist in two different regions (e.g. a + "Ленина" in both oblast 66 and Moscow's region-77 corpus), so a run scoped + to one region must never touch the other's rows even if address/city + happen to collide. + Returns the number of deal rows updated. """ result = db.execute( @@ -300,14 +322,15 @@ def _update_deals_geocoded( " geocode_tried_at = NOW() " " WHERE address = CAST(:addr AS text) " " AND city IS NOT DISTINCT FROM CAST(:city AS text) " + " AND region_code = CAST(:region_code AS int) " " AND lat IS NULL" ), - {"addr": address, "city": city, "lat": lat, "lon": lon}, + {"addr": address, "city": city, "lat": lat, "lon": lon, "region_code": region_code}, ) return result.rowcount -def _mark_deals_tried(db: Session, *, address: str, city: str | None) -> int: +def _mark_deals_tried(db: Session, *, address: str, city: str | None, region_code: int = 66) -> int: """Stamp `geocode_tried_at = NOW()` WITHOUT touching lat/lon (geocode miss). Critical for resume: an address the geocoder can't resolve must still drop @@ -316,8 +339,9 @@ def _mark_deals_tried(db: Session, *, address: str, city: str | None) -> int: it for `stale_days`. The `AND lat IS NULL` guard means a concurrent success can't be downgraded. - Scoped to the (address, city) pair for the same reason as the coords write - — a miss in one city must not defer the other city's retry (#2603). + Scoped to the (address, city, region_code) pair for the same reason as the + coords write — a miss in one city/region must not defer another + region's retry (#2603, #3051). Returns the number of deal rows stamped. """ @@ -327,9 +351,10 @@ def _mark_deals_tried(db: Session, *, address: str, city: str | None) -> int: " SET geocode_tried_at = NOW() " " WHERE address = CAST(:addr AS text) " " AND city IS NOT DISTINCT FROM CAST(:city AS text) " + " AND region_code = CAST(:region_code AS int) " " AND lat IS NULL" ), - {"addr": address, "city": city}, + {"addr": address, "city": city, "region_code": region_code}, ) return result.rowcount @@ -345,6 +370,7 @@ async def _run_backfill( *, batch: str, dry_run: bool, + region_code: int = 66, ) -> Stats: """For each (address, city) pair: geocode once, then UPDATE all its deals. @@ -357,17 +383,21 @@ async def _run_backfill( retry/backoff), so we don't add our own sleep here — that would double the walltime. A geocode that raises is treated as a failure for THIS run but is NOT stamped (left for the next pass to retry sooner than a clean miss). + + `region_code` (#3051, default 66) threads into `known_city_hint`/`geocode` + (region's own city dictionary + bbox/viewbox) and into the two DB writers + (extra WHERE guard, matches `_select_pending_addresses`'s filter). """ stats = Stats() for i, group in enumerate(groups, start=1): address = group.address city = group.city - # Only a recognised oblast-66 city is fed to the geocoder; junk Rosreestr - # values degrade to None (geocoder.known_city_hint — shared with the - # other DB-column callers). The raw `city` is still used for the UPDATE - # scope — it identifies the group either way. - hint = known_city_hint(city) + # Only a recognised city of `region_code` is fed to the geocoder; junk + # Rosreestr values degrade to None (geocoder.known_city_hint — shared + # with the other DB-column callers). The raw `city` is still used for + # the UPDATE scope — it identifies the group either way. + hint = known_city_hint(city, region_code) # The geocoder itself rejects <3 chars, but skip here too so the dry-run # report and counters stay honest (no phantom "processed" address). @@ -377,7 +407,7 @@ async def _run_backfill( result: GeocodeResult | None = None try: - result = await geocode(address, db, city_hint=hint) + result = await geocode(address, db, city_hint=hint, region_code=region_code) except Exception as exc: # defensive — one geocode error must not kill batch stats.geocode_failed += 1 stats.processed += 1 @@ -407,7 +437,7 @@ async def _run_backfill( else: try: with db.begin_nested(): - _mark_deals_tried(db, address=address, city=city) + _mark_deals_tried(db, address=address, city=city, region_code=region_code) db.commit() except Exception as exc: # defensive — isolate one bad UPDATE db.rollback() @@ -441,7 +471,12 @@ async def _run_backfill( try: with db.begin_nested(): n = _update_deals_geocoded( - db, address=address, city=city, lat=result.lat, lon=result.lon + db, + address=address, + city=city, + lat=result.lat, + lon=result.lon, + region_code=region_code, ) # Per-address commit so resume picks up exactly where we crashed. db.commit() @@ -562,6 +597,16 @@ def _parse_args(argv: list[str] | None = None) -> argparse.Namespace: f"{_DEFAULT_STALE_DAYS}). Bounds retries on un-geocodable addresses." ), ) + p.add_argument( + "--region-code", + type=int, + default=66, + help=( + "deals.region_code to geocode (default 66, Sverdlovsk oblast). #3051: " + "77 (Moscow) threads through the SQL filter, geocoder.geocode/" + "known_city_hint, and the DB writers' WHERE guard." + ), + ) return p.parse_args(argv) @@ -575,34 +620,44 @@ async def main(argv: list[str] | None = None) -> int: """ args = _parse_args(argv) logger.info( - "starting batch=%s limit=%s stale_days=%s dry_run=%s", + "starting batch=%s limit=%s stale_days=%s region_code=%s dry_run=%s", args.batch, args.limit, args.stale_days, + args.region_code, args.dry_run, ) db = SessionLocal() try: - groups = _select_pending_addresses(db, limit=args.limit, stale_days=args.stale_days) + groups = _select_pending_addresses( + db, limit=args.limit, stale_days=args.stale_days, region_code=args.region_code + ) total_rows = sum(g.deals_count for g in groups) logger.info( - "loaded %d distinct (address, city) groups (%d deal rows) needing coords", + "loaded %d distinct (address, city) groups (%d deal rows) needing coords " + "region_code=%s", len(groups), total_rows, + args.region_code, ) if not groups: logger.info( "nothing to do — no deals with lat IS NULL eligible (all tried " - "within the last %d days, or no addressable rows)", + "within the last %d days, or no addressable rows) region_code=%s", args.stale_days, + args.region_code, ) return 0 - stats = await _run_backfill(db, groups, batch=args.batch, dry_run=args.dry_run) + stats = await _run_backfill( + db, groups, batch=args.batch, dry_run=args.dry_run, region_code=args.region_code + ) if args.dry_run: - total_streets, backlog_rows = _count_pending_total(db, stale_days=args.stale_days) + total_streets, backlog_rows = _count_pending_total( + db, stale_days=args.stale_days, region_code=args.region_code + ) _report_dry_run( stats, total_streets=total_streets, @@ -611,9 +666,10 @@ async def main(argv: list[str] | None = None) -> int: ) logger.info( - "done: batch=%s processed=%d geocoded=%d geocode_failed=%d " + "done: batch=%s region_code=%s processed=%d geocoded=%d geocode_failed=%d " "skipped=%d deals_updated=%d cache=(hit=%d miss=%d)", args.batch, + args.region_code, stats.processed, stats.geocoded, stats.geocode_failed, diff --git a/tradein-mvp/backend/tests/scripts/test_geocode_deals_nominatim.py b/tradein-mvp/backend/tests/scripts/test_geocode_deals_nominatim.py index 3e656a98..2906ff13 100644 --- a/tradein-mvp/backend/tests/scripts/test_geocode_deals_nominatim.py +++ b/tradein-mvp/backend/tests/scripts/test_geocode_deals_nominatim.py @@ -337,6 +337,7 @@ def test_update_deals_geocoded_sets_lat_lon_tried_at_not_geom(): "city": "Екатеринбург", "lat": 56.1, "lon": 60.2, + "region_code": 66, } assert n == 7 @@ -492,7 +493,7 @@ async def test_run_backfill_passes_known_city_as_hint(): ) as mock_geo: await _run_backfill(db, groups, batch="b", dry_run=False) - mock_geo.assert_called_once_with("Победы, 30", db, city_hint="Нижний Тагил") + mock_geo.assert_called_once_with("Победы, 30", db, city_hint="Нижний Тагил", region_code=66) assert coord_updates[0]["city"] == "Нижний Тагил" @@ -507,7 +508,7 @@ async def test_run_backfill_junk_city_geocodes_without_hint_but_scopes_update(): ) as mock_geo: await _run_backfill(db, groups, batch="b", dry_run=False) - mock_geo.assert_called_once_with("Бессонова, 11", db, city_hint=None) + mock_geo.assert_called_once_with("Бессонова, 11", db, city_hint=None, region_code=66) assert coord_updates[0]["city"] == "Бессонова" @@ -547,7 +548,7 @@ async def test_run_backfill_miss_marks_only_its_own_city(): await _run_backfill(db, groups, batch="b", dry_run=False) assert coord_updates == [] - assert tried_updates == [{"addr": "Победы, 30", "city": "Нижний Тагил"}] + assert tried_updates == [{"addr": "Победы, 30", "city": "Нижний Тагил", "region_code": 66}] # --------------------------------------------------------------------------- diff --git a/tradein-mvp/backend/tests/tasks/test_geocode_missing.py b/tradein-mvp/backend/tests/tasks/test_geocode_missing.py index 9646d66c..3479792b 100644 --- a/tradein-mvp/backend/tests/tasks/test_geocode_missing.py +++ b/tradein-mvp/backend/tests/tasks/test_geocode_missing.py @@ -806,7 +806,7 @@ async def test_admin_geocode_missing_passes_city_hint(target: str) -> None: target=target, # type: ignore[arg-type] ) - mock_geo.assert_called_once_with("ул. Победы, 30", db, city_hint="Нижний Тагил") + mock_geo.assert_called_once_with("ул. Победы, 30", db, city_hint="Нижний Тагил", region_code=66) assert result["geocoded"] == 1 assert result["skipped"] == 0 # SELECT адресован именно запрошенной таблице (обе несут колонку city). @@ -854,7 +854,7 @@ async def test_admin_geocode_missing_drops_junk_city_hint(target: str) -> None: target=target, # type: ignore[arg-type] ) - mock_geo.assert_called_once_with("ул. Бессонова, 11", db, city_hint=None) + mock_geo.assert_called_once_with("ул. Бессонова, 11", db, city_hint=None, region_code=66) @pytest.mark.asyncio diff --git a/tradein-mvp/backend/tests/test_geocoder_city_hint.py b/tradein-mvp/backend/tests/test_geocoder_city_hint.py index 7daa2023..aa7d5f6b 100644 --- a/tradein-mvp/backend/tests/test_geocoder_city_hint.py +++ b/tradein-mvp/backend/tests/test_geocoder_city_hint.py @@ -420,7 +420,7 @@ async def test_geocode_cache_does_not_mix_cities() -> None: def fake_cache_put(db, addr_norm, result): store[addr_norm] = result - async def fake_nominatim_lookup(address, city_hint=None): + async def fake_nominatim_lookup(address, city_hint=None, region_code=66): if city_hint == "Нижний Тагил": return GeocodeResult( lat=57.905, lon=59.950, full_address="Нижний Тагил, Ленина, 1", provider="nominatim" -- 2.45.3