Merge pull request 'feat(tradein/geocoder): регион-параметризация геокодера — region_code в geocode()/known_city_hint, --region-code у скрипта сделок, region_code у geocode-missing (#3051)' (#3432) from feat/3051-geocoder-region-param into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 4m18s
Deploy Trade-In / build-backend (push) Successful in 1m5s
Deploy Trade-In / deploy (push) Successful in 2m11s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 11s

This commit is contained in:
lekss361 2026-09-08 23:58:57 +00:00
commit 6de69c4d05
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)], db: Annotated[Session, Depends(get_db)],
limit: int = 100, limit: int = 100,
target: Literal["listings", "deals"] = "listings", target: Literal["listings", "deals"] = "listings",
region_code: int = 66,
) -> dict: ) -> dict:
"""Геокодинг listings ИЛИ deals у которых нет lat/lon (используя address). """Геокодинг listings ИЛИ deals у которых нет lat/lon (используя address).
@ -279,6 +280,10 @@ async def geocode_missing(
geocode_tried_at: после КАЖДОЙ попытки (успех/провал) ставим NOW(). Failed- geocode_tried_at: после КАЖДОЙ попытки (успех/провал) ставим NOW(). Failed-
адреса не выбираются повторно 7 дней cron-loop завершается, не зацикливается. адреса не выбираются повторно 7 дней cron-loop завершается, не зацикливается.
geom обновляется автоматически триггером. geom обновляется автоматически триггером.
region_code (дефолт 66, #3051) — фильтрует обе таблицы (`listings`/`deals`
несут колонку) и прокидывается в `known_city_hint`/`geocode`. Дефолт 66
прежнее поведение без изменений (cron не меняется, follow-up ниже).
""" """
# Доп. фильтр для listings — у Avito встречаются плейсхолдер-адреса. # Доп. фильтр для listings — у Avito встречаются плейсхолдер-адреса.
extra_filter = "AND address NOT LIKE '%(Avito)%'" if target == "listings" else "" extra_filter = "AND address NOT LIKE '%(Avito)%'" if target == "listings" else ""
@ -291,13 +296,14 @@ async def geocode_missing(
WHERE lat IS NULL WHERE lat IS NULL
AND COALESCE(address, '') != '' AND COALESCE(address, '') != ''
{extra_filter} {extra_filter}
AND region_code = CAST(:region_code AS int)
AND (geocode_tried_at IS NULL AND (geocode_tried_at IS NULL
OR geocode_tried_at < NOW() - interval '7 days') OR geocode_tried_at < NOW() - interval '7 days')
ORDER BY geocode_tried_at NULLS FIRST ORDER BY geocode_tried_at NULLS FIRST
LIMIT :limit LIMIT :limit
""" """
), ),
{"limit": limit}, {"limit": limit, "region_code": region_code},
) )
.mappings() .mappings()
.all() .all()
@ -332,8 +338,8 @@ async def geocode_missing(
# хинт закрывает EKB-локальные тиры и уезжает префиксом в запрос # хинт закрывает EKB-локальные тиры и уезжает префиксом в запрос
# провайдеру, т.е. вреднее отсутствия хинта. Общий хелпер, тот же, что у # провайдеру, т.е. вреднее отсутствия хинта. Общий хелпер, тот же, что у
# scripts/geocode_deals_nominatim.py и tasks/geocode_missing.py. # scripts/geocode_deals_nominatim.py и tasks/geocode_missing.py.
city = known_city_hint(row.get("city")) city = known_city_hint(row.get("city"), region_code)
result = await geocode(clean, db, city_hint=city) result = await geocode(clean, db, city_hint=city, region_code=region_code)
if result is None: if result is None:
# Помечаем что пробовали — иначе ретрай на каждом cron. # Помечаем что пробовали — иначе ретрай на каждом cron.
db.execute( db.execute(
@ -362,10 +368,12 @@ async def geocode_missing(
WHERE lat IS NULL WHERE lat IS NULL
AND COALESCE(address, '') != '' AND COALESCE(address, '') != ''
{extra_filter} {extra_filter}
AND region_code = CAST(:region_code AS int)
AND (geocode_tried_at IS NULL AND (geocode_tried_at IS NULL
OR geocode_tried_at < NOW() - interval '7 days') OR geocode_tried_at < NOW() - interval '7 days')
""" """
) ),
{"region_code": region_code},
).scalar() ).scalar()
return { return {

View file

@ -28,9 +28,16 @@ from tenacity import retry, stop_after_attempt, wait_exponential
from app.core.config import settings from app.core.config import settings
from app.services import dadata from app.services import dadata
from app.services.regions import REGIONS as _ALL_REGIONS from app.services.regions import REGIONS as _ALL_REGIONS
from app.services.regions import Region, is_within_bbox
_REGION_66 = _ALL_REGIONS[66] _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__) logger = logging.getLogger(__name__)
# ── Общий ограничитель темпа обращений к Nominatim (#2953) ────────────────── # ── Общий ограничитель темпа обращений к Nominatim (#2953) ──────────────────
@ -170,8 +177,15 @@ def is_within_oblast66_bbox(lat: float, lon: float) -> bool:
SVERDLOVSK_OBLAST_CITIES = _REGION_66.cities # #3051: список — в реестре регионов SVERDLOVSK_OBLAST_CITIES = _REGION_66.cities # #3051: список — в реестре регионов
def known_city_hint(value: str | None) -> str | None: def known_city_hint(value: str | None, region_code: int = 66) -> str | None:
"""`value` как city_hint, если это узнаваемое имя города региона 66, иначе 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()` Для callers, которые берут город из КОЛОНКИ БД и передают его в `geocode()`
(#2603): `deals.city` — росреестровое поле, заполнено на 100%, но в хвосте (#2603): `deals.city` — росреестровое поле, заполнено на 100%, но в хвосте
@ -200,7 +214,14 @@ def known_city_hint(value: str | None) -> str | None:
""" """
if not value: if not value:
return None 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": ...}]`. # Значение для DaData-констрейнта `locations: [{"region": ...}]`.
@ -231,20 +252,41 @@ _OBLAST_MARKER_RE = re.compile(r"\bсвердловск\w*\b")
_DISTRICT_PREFIXES = frozenset({"мкр", "мкр.", "микрорайон", "р", "р-он", "район", "жк"}) _DISTRICT_PREFIXES = frozenset({"мкр", "мкр.", "микрорайон", "р", "р-он", "район", "жк"})
def _has_oblast_marker(text_lower: str) -> bool: # region_code → скомпилированный regex городов региона (word-boundary), кэш по
"""True если текст уже содержит упоминание области/города региона 66. # коду. 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()) normalized = " ".join(text_lower.split())
if _OBLAST_MARKER_RE.search(normalized): if region_code == 66 and _OBLAST_MARKER_RE.search(normalized):
return True 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() prefix_words = normalized[: m.start()].split()
if prefix_words and prefix_words[-1] in _DISTRICT_PREFIXES: if prefix_words and prefix_words[-1] in _DISTRICT_PREFIXES:
continue # «мкр Заречный» — район, не город-ЗАТО Заречный continue # «мкр Заречный» — район, не город-ЗАТО Заречный
@ -252,12 +294,14 @@ def _has_oblast_marker(text_lower: str) -> bool:
return False 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), когда сам текст адреса города не называет. (Nominatim), когда сам текст адреса города не называет.
Приоритет: Приоритет:
1. Адрес уже содержит маркер города/области региона 66 (`_has_oblast_marker`) 1. Адрес уже содержит маркер города/области `region_code` (`_has_oblast_marker`)
город уже указан пользователем в тексте адреса, ничего подставлять не город уже указан пользователем в тексте адреса, ничего подставлять не
нужно. Возвращает (None, True). нужно. Возвращает (None, True).
2. `city_hint` передан вызывающим кодом (например, фронт знает выбранный 2. `city_hint` передан вызывающим кодом (например, фронт знает выбранный
@ -266,15 +310,18 @@ def _resolve_city_for_geocode(address: str, city_hint: str | None) -> tuple[str
для жителей других городов области это давало уверенно неверную цену для жителей других городов области это давало уверенно неверную цену
(«Ленина, 1» в Нижнем Тагиле снапалось на екатеринбургскую улицу Ленина, («Ленина, 1» в Нижнем Тагиле снапалось на екатеринбургскую улицу Ленина,
обе улицы называются одинаково). Теперь НЕ подставляем никакой город обе улицы называются одинаково). Теперь НЕ подставляем никакой город
провайдер ищет по OBLAST66 viewbox/bbox (см. `OBLAST66_VIEWBOX`), без провайдер ищет по region-viewbox/bbox (см. `OBLAST66_VIEWBOX`,
привязки к конкретному городу. Возвращает `_region_viewbox`), без привязки к конкретному городу. Возвращает
(None, False) второй элемент False сигнализирует, что город (None, False) второй элемент False сигнализирует, что город
пользователь НЕ указывал (источник `GeocodeResult.city_ambiguous`). пользователь НЕ указывал (источник `GeocodeResult.city_ambiguous`).
`region_code` (дефолт 66, #3051) — byte-identical прежнему поведению для
всех вызовов без аргумента.
Returns: Returns:
(city_or_none, city_specified_by_user). (city_or_none, city_specified_by_user).
""" """
if _has_oblast_marker(address.lower()): if _has_oblast_marker(address.lower(), region_code):
return None, True return None, True
hint = (city_hint or "").strip() hint = (city_hint or "").strip()
if hint: if hint:
@ -678,13 +725,15 @@ def _cache_put(db: Session, address_norm: str, result: GeocodeResult) -> None:
# ── Provider: Nominatim (OSM, без ключа) ──────────────────────────────────── # ── 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). """Кросс-чек региона по Nominatim `address.state` (доступно т.к. addressdetails=1).
True/False если state однозначно про/не про Свердловскую область. None если True/False если state однозначно про/не про регион `region_code`. None если
поле отсутствует/не строка тогда accept-логика падает обратно на bbox. поле отсутствует/не строка, ИЛИ регион не имеет записи в `_REGION_STATE_MARKERS`
тогда accept-логика падает обратно на bbox.
Ловит Тюмень/Шадринск/Кунгур/Снежинск они внутри генерального OBLAST66_BBOX Ловит Тюмень/Шадринск/Кунгур/Снежинск они внутри генерального OBLAST66_BBOX
(специально щедрого), но их state явно другой регион. (специально щедрого), но их state явно другой регион. `region_code=66`
(дефолт) byte-identical прежнему поведению (`"свердловск" in state.lower()`).
""" """
addr = item.get("address") addr = item.get("address")
if not isinstance(addr, dict): if not isinstance(addr, dict):
@ -692,22 +741,41 @@ def _nominatim_region_ok(item: dict) -> bool | None:
state = addr.get("state") state = addr.get("state")
if not isinstance(state, str) or not state: if not isinstance(state, str) or not state:
return None 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. """Single Nominatim search. Возвращает лучший item или None.
ВАЖНО: фильтруем результаты по bbox области (region 66) прямо тут, чтобы при ВАЖНО: фильтруем результаты по bbox региона `region_code` прямо тут, чтобы
опечатках не возвращать Пермский край / Челябинск но не резать легитимные при опечатках не возвращать Пермский край / Челябинск но не резать
Нижний Тагил / Серов и т.д. (генеральный bbox всей Свердловской области). легитимные Нижний Тагил / Серов и т.д. (генеральный bbox всего региона).
Two-pass tie-break: среди кандидатов предпочитаем того, кто попадает в TIGHT Two-pass tie-break: среди кандидатов предпочитаем того, кто попадает в TIGHT
ЕКБ-bbox (byte-identical для ЕКБ-запросов, даже если Nominatim ранжировал его bbox региона (byte-identical для region_code=66, даже если Nominatim
не первым) иначе первый кандидат внутри OBLAST66. Плюс region cross-check ранжировал его не первым) иначе первый кандидат внутри генерального bbox
(`address.state`) отсекает кандидатов ЯВНО из другого региона (Тюмень и региона. Плюс region cross-check (`address.state`) отсекает кандидатов
т.п.), даже если координаты попали в генеральный bbox. ЯВНО из другого региона (Тюмень и т.п.), даже если координаты попали в
генеральный 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() await _nominatim_throttle()
response = await client.get( response = await client.get(
"https://nominatim.openstreetmap.org/search", "https://nominatim.openstreetmap.org/search",
@ -717,8 +785,8 @@ async def _nominatim_query(client: httpx.AsyncClient, address: str) -> dict | No
"limit": "3", "limit": "3",
"countrycodes": "ru", "countrycodes": "ru",
"addressdetails": "1", "addressdetails": "1",
"viewbox": OBLAST66_VIEWBOX["viewbox"], "viewbox": viewbox,
"bounded": "1", # строго в пределах области (region 66) "bounded": "1", # строго в пределах региона
}, },
) )
response.raise_for_status() response.raise_for_status()
@ -730,11 +798,11 @@ async def _nominatim_query(client: httpx.AsyncClient, address: str) -> dict | No
lon_f = float(item["lon"]) lon_f = float(item["lon"])
except Exception: except Exception:
continue continue
if _nominatim_region_ok(item) is False: if _nominatim_region_ok(item, region_code) is False:
continue # регион явно не Свердловская область — не рассматриваем continue # регион явно не тот, что запрошен — не рассматриваем
if is_within_ekb_bbox(lat_f, lon_f): if is_within_bbox(lat_f, lon_f, region.bbox_tight):
return item # tight-ЕКБ приоритетнее — тот же результат, что и раньше return item # tight-bbox приоритетнее — тот же результат, что и раньше
if oblast_fallback is None and is_within_oblast66_bbox(lat_f, lon_f): if oblast_fallback is None and is_within_bbox(lat_f, lon_f, region.bbox_region):
oblast_fallback = item oblast_fallback = item
return oblast_fallback return oblast_fallback
@ -756,21 +824,25 @@ async def _nominatim_query(client: httpx.AsyncClient, address: str) -> dict | No
# если голый tenacity.RetryError (не httpx-исключение) всплывёт откуда-то ещё # если голый tenacity.RetryError (не httpx-исключение) всплывёт откуда-то ещё
# (belt-and-suspenders для retry-кода без reraise=True, напр. scraper_kit). # (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) @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. """OSM Nominatim — бесплатно, без ключа, 1 req/sec policy.
Бан-policy: User-Agent с email обязателен. Бан-policy: User-Agent с email обязателен.
Tier 1: bounded область (region 66) на оригинальный адрес. Tier 1: bounded регион `region_code` на оригинальный адрес.
Tier 2: bounded область (region 66) на typo-варианты (Цвилинга Цвиллинга). Tier 2: bounded регион `region_code` на typo-варианты (Цвилинга Цвиллинга).
#2580 (C): city_hint, если известен, подставляется в текст запроса — без #2580 (C): city_hint, если известен, подставляется в текст запроса — без
него `_nominatim_query` полагается ТОЛЬКО на oblast66-bbox фильтр + tie-break него `_nominatim_query` полагается ТОЛЬКО на region-bbox фильтр + tie-break
(предпочитает tight-ЕКБ bbox), который для одноимённых улиц ВНУТРИ региона (предпочитает tight bbox региона), который для одноимённых улиц ВНУТРИ
(напр. "Ленина" и в Екатеринбурге, и в с. Свердловское) не различает город. региона (напр. "Ленина" и в Екатеринбурге, и в с. Свердловское) не
Эмпирически подтверждено: "Ленина 1" без города случайное село внутри различает город. Эмпирически подтверждено: "Ленина 1" без города
области; "Нижний Тагил, Ленина 1" корректно резолвится. Nominatim случайное село внутри области; "Нижний Тагил, Ленина 1" корректно
единственный живой внешний провайдер (#2593: Yandex Geocoder удалён) — резолвится. Nominatim единственный живой внешний провайдер (#2593:
city_hint должен реально влиять на его результат, не только на кэш-ключ. Yandex Geocoder удалён) city_hint должен реально влиять на его результат,
не только на кэш-ключ. `region_code=66` (дефолт, #3051) — byte-identical
прежнему поведению для всех вызовов без аргумента.
""" """
headers = { headers = {
"User-Agent": f"TradeInMVP/0.1 (contact: {settings.contact_email})", "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", "Accept-Language": "ru,en;q=0.8",
"Referer": "https://tradein-mvp.local/", "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 query = f"{city}, {address}" if city else address
async with httpx.AsyncClient(timeout=10.0, headers=headers) as client: async with httpx.AsyncClient(timeout=10.0, headers=headers) as client:
# Tier 1: оригинал # Tier 1: оригинал
item = await _nominatim_query(client, query) item = await _nominatim_query(client, query, region_code)
# Tier 2: typo-variants # Tier 2: typo-variants
if item is None: if item is None:
for variant in _typo_variants(address, limit=4): 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 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: if item is not None:
logger.info("nominatim typo-fixed: %s%s", address, variant) logger.info("nominatim typo-fixed: %s%s", address, variant)
break break
@ -1753,7 +1825,9 @@ async def suggest(
# ── Public API ─────────────────────────────────────────────────────────────── # ── 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). """Геокодинг с кэшем + постфактум-проверка подмены города (#2590).
Тонкая обёртка над `_geocode_resolve` (вся тировая цепочка там). Инвариант Тонкая обёртка над `_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): if result is None or not _city_substituted(address, result):
return result return result
logger.warning( logger.warning(
@ -1788,7 +1875,7 @@ async def geocode(address: str, db: Session, city_hint: str | None = None) -> Ge
async def _geocode_resolve( 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: ) -> GeocodeResult | None:
"""Геокодинг с кэшем. Cadastral FDW → Nominatim → None. """Геокодинг с кэшем. Cadastral FDW → Nominatim → None.
@ -1802,6 +1889,10 @@ async def _geocode_resolve(
участвует в cache-ключе (см. `_cache_key`), чтобы ответы для участвует в cache-ключе (см. `_cache_key`), чтобы ответы для
разных городов по одному и тому же тексту адреса не перезатирали разных городов по одному и тому же тексту адреса не перезатирали
друг друга. друг друга.
region_code: регион покрытия (дефолт 66, #3051). ЕКБ-only локальные
тиры (geoportal/cad_buildings/houses) применяются ТОЛЬКО при 66
это ЕКБ-специфичные реестры, у других регионов данных в них нет.
Прокидывается в Nominatim-тир (bbox/viewbox/city-словарь).
Returns: Returns:
GeocodeResult или None если ни один провайдер не отвечает. GeocodeResult или None если ни один провайдер не отвечает.
@ -1812,7 +1903,7 @@ async def _geocode_resolve(
if not address or len(address.strip()) < 3: if not address or len(address.strip()) < 3:
return None 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 city_ambiguous = not city_specified
addr_norm = _cache_key(normalize_address(address), city_hint) addr_norm = _cache_key(normalize_address(address), city_hint)
@ -1838,7 +1929,14 @@ async def _geocode_resolve(
# Раньше решение по тексту адреса принималось от противного (список из 37 # Раньше решение по тексту адреса принималось от противного (список из 37
# городов — «нет в списке → считаем ЕКБ»), из-за чего любой другой регион # городов — «нет в списке → считаем ЕКБ»), из-за чего любой другой регион
# РФ (Ялта, Трёхгорный) молча резолвился в координаты ЕКБ (#2582). # РФ (Ялта, Трёхгорный) молча резолвился в координаты ЕКБ (#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%). # 2a. Геопортал ЕКБ — ПЕРВЫЙ локальный tier (полнее cad_buildings ~на 70%).
if use_local_ekb and parsed is not None: if use_local_ekb and parsed is not None:
@ -1914,7 +2012,7 @@ async def _geocode_resolve(
# 3. Nominatim fallback # 3. Nominatim fallback
try: try:
result = await _nominatim_lookup(address, city_hint) result = await _nominatim_lookup(address, city_hint, region_code)
if result is not None: if result is not None:
result = replace(result, city_ambiguous=city_ambiguous) result = replace(result, city_ambiguous=city_ambiguous)
await asyncio.to_thread(_cache_put, db, addr_norm, result) 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. plus the geocode hit/miss split. No DB writes.
--batch LABEL log label (default `deals_nominatim_YYYY-MM-DD`). --batch LABEL log label (default `deals_nominatim_YYYY-MM-DD`).
--stale-days N retry addresses last tried more than N days ago (default 30). --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 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. """Distinct (address, city) pairs still needing coords — resume-safe set.
Combines the `deals_geocode_pending_idx` partial index predicate 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 " " WHERE lat IS NULL "
" AND address IS NOT NULL " " AND address IS NOT NULL "
" AND length(trim(address)) >= 3 " " AND length(trim(address)) >= 3 "
" AND region_code = CAST(:region_code AS int) "
" AND (geocode_tried_at IS NULL " " AND (geocode_tried_at IS NULL "
" OR geocode_tried_at < NOW() " " OR geocode_tried_at < NOW() "
" - make_interval(days => CAST(:stale_days AS int))) " " - 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) " "WHERE running_rows - deals_count < CAST(:limit AS int) "
"ORDER BY deals_count DESC, address ASC, city ASC NULLS FIRST" "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() .mappings()
.all() .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. """Full backlog: (distinct (address, city) pairs, total rows) this pass.
Denominators for the dry-run projection counts every lat-IS-NULL deal 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 " "WHERE lat IS NULL "
" AND address IS NOT NULL " " AND address IS NOT NULL "
" AND length(trim(address)) >= 3 " " AND length(trim(address)) >= 3 "
" AND region_code = CAST(:region_code AS int) "
" AND (geocode_tried_at IS NULL " " AND (geocode_tried_at IS NULL "
" OR geocode_tried_at < NOW() " " OR geocode_tried_at < NOW() "
" - make_interval(days => CAST(:stale_days AS int)))" " - make_interval(days => CAST(:stale_days AS int)))"
), ),
{"stale_days": stale_days}, {"stale_days": stale_days, "region_code": region_code},
).first() ).first()
if row is None: if row is None:
return (0, 0) return (0, 0)
@ -274,9 +284,15 @@ def _count_pending_total(db: Session, *, stale_days: int) -> tuple[int, int]:
def _update_deals_geocoded( 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: ) -> 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 The `deals_set_geom_trg` BEFORE UPDATE OF lat, lon trigger
(002_core_tables.sql, reuses listings_set_geom()) populates geom from the (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 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). 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. Returns the number of deal rows updated.
""" """
result = db.execute( result = db.execute(
@ -300,14 +322,15 @@ def _update_deals_geocoded(
" geocode_tried_at = NOW() " " geocode_tried_at = NOW() "
" WHERE address = CAST(:addr AS text) " " WHERE address = CAST(:addr AS text) "
" AND city IS NOT DISTINCT FROM CAST(:city AS text) " " AND city IS NOT DISTINCT FROM CAST(:city AS text) "
" AND region_code = CAST(:region_code AS int) "
" AND lat IS NULL" " 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 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). """Stamp `geocode_tried_at = NOW()` WITHOUT touching lat/lon (geocode miss).
Critical for resume: an address the geocoder can't resolve must still drop 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 it for `stale_days`. The `AND lat IS NULL` guard means a concurrent success
can't be downgraded. can't be downgraded.
Scoped to the (address, city) pair for the same reason as the coords write Scoped to the (address, city, region_code) pair for the same reason as the
a miss in one city must not defer the other city's retry (#2603). coords write a miss in one city/region must not defer another
region's retry (#2603, #3051).
Returns the number of deal rows stamped. 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() " " SET geocode_tried_at = NOW() "
" WHERE address = CAST(:addr AS text) " " WHERE address = CAST(:addr AS text) "
" AND city IS NOT DISTINCT FROM CAST(:city AS text) " " AND city IS NOT DISTINCT FROM CAST(:city AS text) "
" AND region_code = CAST(:region_code AS int) "
" AND lat IS NULL" " AND lat IS NULL"
), ),
{"addr": address, "city": city}, {"addr": address, "city": city, "region_code": region_code},
) )
return result.rowcount return result.rowcount
@ -345,6 +370,7 @@ async def _run_backfill(
*, *,
batch: str, batch: str,
dry_run: bool, dry_run: bool,
region_code: int = 66,
) -> Stats: ) -> Stats:
"""For each (address, city) pair: geocode once, then UPDATE all its deals. """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 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 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). 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() stats = Stats()
for i, group in enumerate(groups, start=1): for i, group in enumerate(groups, start=1):
address = group.address address = group.address
city = group.city city = group.city
# Only a recognised oblast-66 city is fed to the geocoder; junk Rosreestr # Only a recognised city of `region_code` is fed to the geocoder; junk
# values degrade to None (geocoder.known_city_hint — shared with the # Rosreestr values degrade to None (geocoder.known_city_hint — shared
# other DB-column callers). The raw `city` is still used for the UPDATE # with the other DB-column callers). The raw `city` is still used for
# scope — it identifies the group either way. # the UPDATE scope — it identifies the group either way.
hint = known_city_hint(city) hint = known_city_hint(city, region_code)
# The geocoder itself rejects <3 chars, but skip here too so the dry-run # The geocoder itself rejects <3 chars, but skip here too so the dry-run
# report and counters stay honest (no phantom "processed" address). # report and counters stay honest (no phantom "processed" address).
@ -377,7 +407,7 @@ async def _run_backfill(
result: GeocodeResult | None = None result: GeocodeResult | None = None
try: 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 except Exception as exc: # defensive — one geocode error must not kill batch
stats.geocode_failed += 1 stats.geocode_failed += 1
stats.processed += 1 stats.processed += 1
@ -407,7 +437,7 @@ async def _run_backfill(
else: else:
try: try:
with db.begin_nested(): 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() db.commit()
except Exception as exc: # defensive — isolate one bad UPDATE except Exception as exc: # defensive — isolate one bad UPDATE
db.rollback() db.rollback()
@ -441,7 +471,12 @@ async def _run_backfill(
try: try:
with db.begin_nested(): with db.begin_nested():
n = _update_deals_geocoded( 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. # Per-address commit so resume picks up exactly where we crashed.
db.commit() 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." 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) return p.parse_args(argv)
@ -575,34 +620,44 @@ async def main(argv: list[str] | None = None) -> int:
""" """
args = _parse_args(argv) args = _parse_args(argv)
logger.info( 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.batch,
args.limit, args.limit,
args.stale_days, args.stale_days,
args.region_code,
args.dry_run, args.dry_run,
) )
db = SessionLocal() db = SessionLocal()
try: 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) total_rows = sum(g.deals_count for g in groups)
logger.info( 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), len(groups),
total_rows, total_rows,
args.region_code,
) )
if not groups: if not groups:
logger.info( logger.info(
"nothing to do — no deals with lat IS NULL eligible (all tried " "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.stale_days,
args.region_code,
) )
return 0 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: 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( _report_dry_run(
stats, stats,
total_streets=total_streets, total_streets=total_streets,
@ -611,9 +666,10 @@ async def main(argv: list[str] | None = None) -> int:
) )
logger.info( 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)", "skipped=%d deals_updated=%d cache=(hit=%d miss=%d)",
args.batch, args.batch,
args.region_code,
stats.processed, stats.processed,
stats.geocoded, stats.geocoded,
stats.geocode_failed, stats.geocode_failed,

View file

@ -337,6 +337,7 @@ def test_update_deals_geocoded_sets_lat_lon_tried_at_not_geom():
"city": "Екатеринбург", "city": "Екатеринбург",
"lat": 56.1, "lat": 56.1,
"lon": 60.2, "lon": 60.2,
"region_code": 66,
} }
assert n == 7 assert n == 7
@ -492,7 +493,7 @@ async def test_run_backfill_passes_known_city_as_hint():
) as mock_geo: ) as mock_geo:
await _run_backfill(db, groups, batch="b", dry_run=False) 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"] == "Нижний Тагил" 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: ) as mock_geo:
await _run_backfill(db, groups, batch="b", dry_run=False) 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"] == "Бессонова" 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) await _run_backfill(db, groups, batch="b", dry_run=False)
assert coord_updates == [] 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] 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["geocoded"] == 1
assert result["skipped"] == 0 assert result["skipped"] == 0
# SELECT адресован именно запрошенной таблице (обе несут колонку city). # 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] 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 @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): def fake_cache_put(db, addr_norm, result):
store[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 == "Нижний Тагил": if city_hint == "Нижний Тагил":
return GeocodeResult( return GeocodeResult(
lat=57.905, lon=59.950, full_address="Нижний Тагил, Ленина, 1", provider="nominatim" lat=57.905, lon=59.950, full_address="Нижний Тагил, Ленина, 1", provider="nominatim"