fix(tradein/geocoder): инвариант «чужой топоним + результат внутри ЕКБ = недостоверно» (#2590) (#2837)
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 3m11s
Deploy Trade-In / build-backend (push) Successful in 1m3s
Deploy Trade-In / deploy (push) Successful in 2m9s

This commit is contained in:
bot-backend 2026-08-12 14:26:58 +00:00
parent ad190be233
commit 677fcb749c
2 changed files with 441 additions and 0 deletions

View file

@ -372,6 +372,134 @@ def _names_unrecognized_locality(address: str) -> bool:
return bool(_LOCALITY_MARKER_RE.search(normalized))
# ── Постфактум-инвариант подмены города (#2590) ──────────────────────────────
# Гейты выше (#2582/#2589) стоят НА ВХОДЕ и решают, пускать ли ЕКБ-only тиры.
# Внешние провайдеры ими не покрыты: «реж, ленина» уходит в Nominatim/Yandex, и
# тот, не найдя Режа, отдаёт улицу Ленина в Екатеринбурге. Отличить на входе
# «Реж» (город) от «Малышева» (улица) без списка городов нельзя — оба «слово до
# запятой». ПОСЛЕ ответа можно: провайдер сам пишет, какой населённый пункт он
# использовал, и если названный топоним туда не дожил — топоним подменён.
#
# Инвариант (#2590): назван топоним ≠ Екатеринбург + его нет в ответе провайдера
# + ответ лежит внутри ЕКБ ⇒ результат недостоверен. Ни одного имени города в
# коде — только уровни РФ-адреса (страна → регион → район → НП → улица → дом) и
# сам целевой город.
_ADDRESS_SEGMENT_RE = re.compile(r"[,;·]")
# Страна: сегмент выше уровня НП. Единственная константа-топоним помимо целевого
# города — продукт РФ-only, новых значений у неё не появится (в отличие от
# списка городов области, ради ухода от которого всё и делается).
_COUNTRY_RE = re.compile(r"\b(?:росси[яи]|russia)\b")
# Уровень «улица/дом»: дойдя до него, НП уже был бы назван (порядок РФ-адреса
# big→small). Дальше идти нельзя — иначе второй уличный сегмент («малышева,
# мопра» — перекрёсток) читается как топоним и ложно отбраковывается.
_STREET_LEVEL_RE = re.compile(
r"\b(?:ул|улица|пер|переулок|пр|пр-кт|пркт|проспект|б-р|бульвар|ш|шоссе|наб|набережная"
r"|пл|площадь|проезд|тракт|аллея|тупик|туп|линия|кв-л|квартал|стр|строение|дом|корп"
r"|корпус|лит|литера|снт|сад|гск)\b"
)
# Уровни ВЫШЕ и НИЖЕ населённого пункта — пропускаем и идём дальше по сегментам:
# «свердловская обл., г.о. рефтинский» (регион → НП), «мкр-н широкая речка, ул.
# …» (район ВНУТРИ города — его провайдер в ответе обычно не повторяет).
_REGION_LEVEL_RE = re.compile(r"\b(?:обл\.?|область|края|край|республика|респ\.?|ао)\b")
_DISTRICT_LEVEL_RE = re.compile(r"\b(?:р-н|р-он|район|мкр|мкр-н|микрорайон|жк|жилой)\b")
# Слова-ТИПЫ НП (не имя): «пос. Кедровка» → имя «кедровка». Тип не сравнивается
# с ответом — провайдер пишет свой («посёлок» vs «пос.»), имя же обязано дожить.
_LOCALITY_TYPE_WORDS = frozenset(
{
"поселок",
"пос",
"село",
"деревня",
"дер",
"город",
"гор",
"округ",
"муниципальный",
"городской",
"сельское",
"поселение",
"тер",
"территория",
"станция",
"пгт",
"рп",
}
)
_WORD_RE = re.compile(r"[а-я][а-я-]*")
def _fold(value: str) -> str:
"""lower + ё→е + схлопывание пробелов — общий канон для сравнения топонимов."""
return " ".join(value.lower().replace("ё", "е").split())
def _claimed_locality(address: str) -> str | None:
"""Имя населённого пункта, названное в тексте адреса, или None.
Структурно, БЕЗ перечисления городов: идём по сегментам в порядке РФ-адреса
(страна регион район НП улица дом), пропускаем уровни выше/ниже
НП, останавливаемся на уровне улицы/дома. Первый оставшийся сегмент имя НП.
None означает «НП не назван» это основной трафик формы («Малышева 30»), и
для него инвариант не применяется вовсе.
"""
for segment in _ADDRESS_SEGMENT_RE.split(_fold(address)):
segment = segment.strip()
if not segment:
continue
if any(ch.isdigit() for ch in segment) or _STREET_LEVEL_RE.search(segment):
return None # улица/дом: будь НП назван, он шёл бы раньше
if (
_COUNTRY_RE.search(segment)
or _REGION_LEVEL_RE.search(segment)
or _DISTRICT_LEVEL_RE.search(segment)
):
continue
name = " ".join(
w for w in _WORD_RE.findall(segment) if w not in _LOCALITY_TYPE_WORDS and len(w) >= 3
)
if name:
return name
return None
def _city_substituted(address: str, result: GeocodeResult) -> bool:
"""True если провайдер подменил названный в адресе НП Екатеринбургом (#2590).
Три условия вместе:
1. в адресе назван НП и это не Екатеринбург (`_claimed_locality`);
2. этого имени НЕТ в адресе, который вернул провайдер то есть топоним не
пережил геокодинг;
3. результат лежит внутри ЕКБ: и по координатам (`EKB_BBOX_TIGHT`), и по
собственному ответу провайдера он называет Екатеринбург либо не
называет НП вовсе (ЕКБ-only локальные реестры отдают «Улица, дом»;
тогда «внутри ЕКБ» подтверждают координаты).
Условие 3 и разводит подмену с посёлками в городской черте. «пос. Кедровка,
Советская ул., 5» ответ «Екатеринбург, Советская улица, 5» имя не дожило,
и это ПРАВДА подмена: настоящая Кедровка в 20 км от улицы Советской. А
корректный ответ по посёлку («Кедровка, Екатеринбург, » Nominatim и Yandex
пишут НП всегда, когда действительно его нашли) имя сохраняет и через фильтр
не проходит. Проверяется не география посёлка, а факт «топоним потерян».
Известный потолок: НП, чьё имя совпало с уличным токеном ответа («Ачит»
«М-12 Ачит-Екатеринбург», «Лесной» «Лесной переулок»), считается дожившим
пропуск, не ложная отбраковка. Обратный потолок: жилрайон ЕКБ, названный без
приставки («пионерский, советская»), понижается до `locality` честная
деградация, координаты не теряются.
"""
claimed = _claimed_locality(address)
if claimed is None or _EKATERINBURG_RE.search(claimed):
return False
answer = _fold(result.full_address or "")
if any(word in answer for word in claimed.split()):
return False # топоним дожил до ответа — провайдер искал там, где просили
if not is_within_ekb_bbox(result.lat, result.lon):
return False
answer_locality = _claimed_locality(answer)
return answer_locality is None or bool(_EKATERINBURG_RE.search(answer_locality))
def _ekb_local_tiers_allowed(address: str, city_hint: str | None = None) -> bool:
"""Fail-closed гейт локальных ЕКБ-тиров geocoder (`geocode()`/`suggest()`, #2582).
@ -1287,6 +1415,42 @@ async def suggest(
# ── Public API ───────────────────────────────────────────────────────────────
async def geocode(address: str, db: Session, city_hint: str | None = None) -> GeocodeResult | None:
"""Геокодинг с кэшем + постфактум-проверка подмены города (#2590).
Тонкая обёртка над `_geocode_resolve` (вся тировая цепочка там). Инвариант
применяется ОДНОЙ точкой на выходе поэтому покрывает разом все источники,
включая попадание в кэш: отравленная запись, записанная до этого фикса,
больше не отдаётся как точная, хотя строка в `geocode_cache` не тронута
(обратимо: откат кода возвращает прежнее поведение, чистить БД не требуется).
Сработал инвариант `confidence="locality"` + `city_ambiguous=True`.
`locality` не косметика: `estimator._geocode_is_coarse` уже трактует его
как «геокодер дошёл только до центра НП» и (а) включает #693 coarse-downgrade
оценки, (б) через `tasks.geocode_missing` проставляет листингу
`geo_precision='city'`, а этот признак исключает листинг из пула аналогов
(`estimator`/`location_index`: `geo_precision IS DISTINCT FROM 'city'`).
То есть объявление, уехавшее координатами в чужой город, перестаёт тянуть
за собой чужие оценки. Координаты НЕ выбрасываются деградация честная и
видимая, а не отказ.
"""
result = await _geocode_resolve(address, db, city_hint)
if result is None or not _city_substituted(address, result):
return result
logger.warning(
"geocode city substitution (#2590): %r%r (%.5f, %.5f) provider=%s"
"названный НП не дожил до ответа, результат внутри ЕКБ; confidence→locality",
address[:80],
(result.full_address or "")[:80],
result.lat,
result.lon,
result.provider,
)
return replace(result, confidence="locality", city_ambiguous=True)
async def _geocode_resolve(
address: str, db: Session, city_hint: str | None = None
) -> GeocodeResult | None:
"""Геокодинг с кэшем. Cadastral FDW → Nominatim → None.
Args:

View file

@ -0,0 +1,277 @@
"""Тесты #2590 — постфактум-инвариант «назван чужой топоним + результат внутри ЕКБ».
Гейты #2582/#2589 стоят НА ВХОДЕ и решают, пускать ли ЕКБ-only локальные реестры.
Внешние провайдеры ими не покрыты: «реж, ленина» уходит в Nominatim/Yandex, тот не
находит Режа и отдаёт улицу Ленина в Екатеринбурге с координатами ЕКБ, пометкой
«точно» и записью в кэш на 90 дней. Замер на проде (`geocode_cache`, 9514 строк,
2026-08-12): **1251 запись** подпадает под инвариант 1245 `provider=yandex`,
6 `nominatim`, 0 у локальных тиров; 365 разных населённых пунктов.
Инвариант проверяется ПОСЛЕ ответа провайдера, потому что на входе «Реж» (город)
и «Малышева» (улица) структурно неразличимы оба «слово до запятой». После
ответа различимы: провайдер сам пишет, какой НП он использовал.
Адреса и full_address в тестах живые строки из прод-кэша (не выдуманные).
"""
from __future__ import annotations
import contextlib
import os
from unittest.mock import AsyncMock, MagicMock, patch
os.environ.setdefault("DATABASE_URL", "postgresql://test:test@localhost/test_db")
import pytest
from app.services.geocoder import (
GeocodeResult,
GeocodeSuggestion,
_city_substituted,
_claimed_locality,
geocode,
)
# ── _claimed_locality: какой НП назван в тексте ──────────────────────────────
@pytest.mark.parametrize(
"address,expected",
[
# НП назван явно
("реж, ленина", "реж"),
("арамиль, красноармейская ул, 118дк1", "арамиль"),
("пос. кедровка, советская ул.,5", "кедровка"), # тип «пос.» отброшен, имя осталось
# Уровни выше НП пропускаются, НП находится дальше
("свердловская обл., г.о. рефтинский", "рефтинский"),
("россия, свердловская область, реж, ленина 5", "реж"),
# НП НЕ назван — основной трафик формы оценки, инвариант не применяется
("малышева 30", None),
("ул. титова/монтерская/смоленская, стр. 2", None),
("улица декабристов, 16-18литж · р-н октябрьский", None),
("мкр-н широкая речка, ул. анатолия муранова,18", None),
# Перекрёсток: второй уличный сегмент НЕ должен читаться как топоним
("малышева, мопра", "малышева"),
],
)
def test_claimed_locality(address: str, expected: str | None) -> None:
assert _claimed_locality(address) == expected
# ── _city_substituted: подмена vs честный ответ ──────────────────────────────
def _res(full_address: str, lat: float, lon: float) -> GeocodeResult:
return GeocodeResult(
lat=lat, lon=lon, full_address=full_address, provider="nominatim", confidence="exact"
)
@pytest.mark.parametrize(
"address,full_address,lat,lon",
[
# Живые записи прод-кэша из тела #2590 (все provider=yandex)
(
"реж, ленина",
"Россия, Свердловская область, Екатеринбург, улица Ленина",
56.83953,
60.61244,
),
(
"реж, краснофлотцев",
"Россия, Свердловская область, Екатеринбург, улица Краснофлотцев",
56.88996,
60.63184,
),
(
"арамиль, заводская",
"Россия, Свердловская область, Екатеринбург, Заводская улица",
56.83043,
60.55170,
),
(
"свердловская обл., г.о. рефтинский",
"Россия, Свердловская область, Екатеринбург",
56.83744,
60.59764,
),
# Города вне списка 37 и без структурной приставки — дыра, оставленная #2589
(
"талица, кузнецова",
"Россия, Свердловская область, Екатеринбург, улица Кузнецова",
56.84226,
60.64557,
),
(
"верхотурье, герцена",
"Россия, Свердловская область, Екатеринбург, улица Герцена",
56.744397,
60.677299,
),
# Посёлок В ЧЕРТЕ ЕКБ, но улица подменена: настоящая Кедровка в 20 км от
# улицы Советской, куда провайдер поставил точку (живая запись прода).
(
"пос. кедровка, советская ул.,5",
"Россия, Свердловская область, Екатеринбург, Советская улица, 5",
56.8506,
60.6282,
),
],
)
def test_substitution_detected(address: str, full_address: str, lat: float, lon: float) -> None:
assert _city_substituted(address, _res(full_address, lat, lon)) is True
@pytest.mark.parametrize(
"address,full_address,lat,lon",
[
# Посёлок в черте ЕКБ, ответ ЧЕСТНЫЙ — имя дожило до full_address.
# Тот же вход, что и в подмене выше: разделяет их не география, а
# сохранность топонима (граница из #2777 — посёлки не ловятся
# «городским» признаком, и здесь ловить их и не нужно).
(
"пос. кедровка, советская ул.,5",
"5, Советская улица, Кедровка, Орджоникидзевский район, Екатеринбург",
56.9902,
60.7166,
),
(
"шувакиш, ленина 4",
"4, улица Ленина, Шувакиш, Железнодорожный район, Екатеринбург",
56.9295,
60.4938,
),
# НП не назван вообще — основной трафик формы, ЕКБ как рабочая гипотеза
(
"малышева 30",
"Россия, Свердловская область, Екатеринбург, улица Малышева, 30",
56.8389,
60.6057,
),
# Явный Екатеринбург в адресе — не подмена по определению
("екатеринбург, ул. репина, 99", "Репина, 99", 56.8207, 60.5686),
("г екатеринбург, ул сурикова, д 47", "Сурикова, 47", 56.8156, 60.5943),
# Честный ответ по другому городу области: провайдер назвал НП, координаты
# в его собственном bbox — под инвариант не подпадает
("нижний тагил, ленина 1", "1, проспект Ленина, Нижний Тагил", 57.9101, 59.9813),
# Ответ внутри ЕКБ-bbox, но провайдер назвал ДРУГОЙ НП (Берёзовский лежит
# внутри щедрого EKB_BBOX_TIGHT) — «внутри Екатеринбурга» не подтверждено
(
"режевской, ленина",
"Россия, Свердловская область, Берёзовский, улица Ленина",
56.9088,
60.8107,
),
# Перекрёсток двух улиц: провайдер взял первую — потеря второй улицы не
# является подменой города
(
"малышева, мопра",
"Россия, Свердловская область, Екатеринбург, улица Малышева",
56.8389,
60.6057,
),
# Тот же НП, но за пределами ЕКБ — инвариант молчит
("реж, ленина", "Россия, Свердловская область, Реж, улица Ленина", 57.3722, 61.3897),
],
)
def test_no_substitution(address: str, full_address: str, lat: float, lon: float) -> None:
assert _city_substituted(address, _res(full_address, lat, lon)) is False
# ── geocode(): сквозной эффект ───────────────────────────────────────────────
@contextlib.contextmanager
def _tiers(*, cached=None, nominatim=None, geoportal=None):
with contextlib.ExitStack() as stack:
stack.enter_context(patch("app.services.geocoder._cache_get", return_value=cached))
stack.enter_context(patch("app.services.geocoder._cache_put"))
stack.enter_context(
patch("app.services.geocoder._geoportal_house_match", return_value=geoportal)
)
stack.enter_context(
patch("app.services.geocoder._cadastral_house_match", return_value=None)
)
stack.enter_context(patch("app.services.geocoder._cadastral_forward_sync", return_value=[]))
stack.enter_context(
patch("app.services.geocoder._nominatim_lookup", new=AsyncMock(return_value=nominatim))
)
stack.enter_context(patch("app.services.geocoder.asyncio.sleep", new=AsyncMock()))
yield
async def test_geocode_downgrades_substituted_result() -> None:
"""«Реж, Ленина 5» → провайдер отдал улицу Ленина в ЕКБ с confidence=exact.
Координаты остаются (честная деградация, не отказ), но confidence падает до
`locality` а это уже рабочий контракт: `estimator._geocode_is_coarse`
включает #693 coarse-downgrade, `tasks.geocode_missing` ставит листингу
`geo_precision='city'`, что исключает его из пула аналогов.
"""
db = MagicMock()
substituted = GeocodeResult(
lat=56.83953,
lon=60.61244,
full_address="Россия, Свердловская область, Екатеринбург, улица Ленина, 5",
provider="nominatim",
confidence="exact",
)
with _tiers(nominatim=substituted):
result = await geocode("Реж, Ленина 5", db)
assert result is not None
assert result.confidence == "locality"
assert result.city_ambiguous is True
assert result.lat == pytest.approx(56.83953) # координаты НЕ выброшены
async def test_geocode_poisoned_cache_hit_downgraded() -> None:
"""Отравленная запись кэша (их 1251 на проде) продолжает отдаваться, но уже
НЕ как точная без единого DELETE в `geocode_cache`."""
db = MagicMock()
poisoned = GeocodeResult(
lat=56.83744,
lon=60.59764,
full_address="Россия, Свердловская область, Екатеринбург",
provider="cache",
confidence="exact",
)
with _tiers(cached=poisoned):
result = await geocode("Арамиль, Красноармейская ул, 118дк1", db)
assert result is not None
assert result.confidence == "locality"
assert result.city_ambiguous is True
async def test_geocode_ekb_address_unaffected() -> None:
"""Контроль: реальный ЕКБ-адрес через локальный тир (full_address без города)
остаётся `exact` инвариант не трогает основной трафик."""
db = MagicMock()
hit = GeocodeSuggestion(
label="Малышева, 30", full_address="Малышева, 30", lat=56.8389, lon=60.6057, kind="house"
)
with _tiers(geoportal=hit):
result = await geocode("Екатеринбург, Малышева 30", db)
assert result is not None
assert result.confidence == "exact"
assert result.city_ambiguous is False
async def test_geocode_settlement_inside_ekb_kept_exact() -> None:
"""Посёлок в городской черте (Кедровка) с ЧЕСТНЫМ ответом провайдера не
отбраковывается граница, о которой предупреждает #2777."""
db = MagicMock()
honest = GeocodeResult(
lat=56.9902,
lon=60.7166,
full_address="5, Советская улица, Кедровка, Орджоникидзевский район, Екатеринбург",
provider="nominatim",
confidence="exact",
)
with _tiers(nominatim=honest):
result = await geocode("пос. Кедровка, Советская ул., 5", db)
assert result is not None
assert result.confidence == "exact"