feat(tradein/geocoder): регион-параметризация геокодера — region_code в geocode()/known_city_hint, --region-code у скрипта сделок, region_code у geocode-missing (#3051) #3432

Merged
lekss361 merged 1 commit from feat/3051-geocoder-region-param into main 2026-09-08 23:58:58 +00:00
6 changed files with 256 additions and 93 deletions

View file

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

View file

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

View file

@ -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='Москва, <street>' 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,

View file

@ -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}]
# ---------------------------------------------------------------------------

View file

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

View file

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