From 48db4991fc5abdcfbdfa618c7c6835f97593a3cc Mon Sep 17 00:00:00 2001 From: bot-backend Date: Wed, 12 Aug 2026 19:20:58 +0500 Subject: [PATCH] =?UTF-8?q?fix(tradein/geocoder):=20=D0=B8=D0=BD=D0=B2?= =?UTF-8?q?=D0=B0=D1=80=D0=B8=D0=B0=D0=BD=D1=82=20=C2=AB=D1=87=D1=83=D0=B6?= =?UTF-8?q?=D0=BE=D0=B9=20=D1=82=D0=BE=D0=BF=D0=BE=D0=BD=D0=B8=D0=BC=20+?= =?UTF-8?q?=20=D1=80=D0=B5=D0=B7=D1=83=D0=BB=D1=8C=D1=82=D0=B0=D1=82=20?= =?UTF-8?q?=D0=B2=D0=BD=D1=83=D1=82=D1=80=D0=B8=20=D0=95=D0=9A=D0=91=20=3D?= =?UTF-8?q?=20=D0=BD=D0=B5=D0=B4=D0=BE=D1=81=D1=82=D0=BE=D0=B2=D0=B5=D1=80?= =?UTF-8?q?=D0=BD=D0=BE=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Гейты #2582/#2589 стоят на входе и решают, пускать ли ЕКБ-only реестры. Внешние провайдеры ими не покрыты: «реж, ленина» уходит в Nominatim/Yandex, тот не находит Режа и отдаёт улицу Ленина в Екатеринбурге — с координатами ЕКБ, пометкой «точно» и записью в кэш на 90 дней. Отличить на входе «Реж» (город) от «Малышева» (улица) без списка городов нельзя — оба «слово до запятой». ПОСЛЕ ответа можно: провайдер сам пишет, какой НП он использовал. Отсюда проверка постфактум: назван топоним ≠ Екатеринбург, его имя не дожило до ответа провайдера, результат лежит внутри ЕКБ (по координатам И по собственному ответу провайдера) ⇒ подмена. Ни одного имени города в коде — только уровни РФ-адреса (страна → регион → район → НП → улица → дом) и сам целевой город. Посёлки в городской черте разделяются не географией, а сохранностью топонима: «Кедровка, Екатеринбург, Советская» имя сохраняет и проходит, «Екатеринбург, Советская улица» на тот же запрос — нет (настоящая Кедровка в 20 км от той улицы). Проверка стоит одной точкой на выходе geocode() — покрывает все тиры разом, включая попадание в кэш: 1251 отравленная запись перестаёт отдаваться как точная без единого DELETE (обратимо откатом кода). Последствие сработки — confidence="locality" + city_ambiguous=True. locality не косметика: estimator._geocode_is_coarse включает #693 coarse-downgrade, а tasks.geocode_missing ставит листингу geo_precision='city', что исключает его из пула аналогов (geo_precision IS DISTINCT FROM 'city'). Координаты не выбрасываются — деградация честная и видимая. Замер на проде (geocode_cache, 9514 строк, 2026-08-12): 1251 запись подпадает под инвариант — 1245 provider=yandex, 6 nominatim, 0 у локальных тиров; 365 разных населённых пунктов. Refs #2590 --- tradein-mvp/backend/app/services/geocoder.py | 164 +++++++++++ .../tests/test_geocoder_city_substitution.py | 277 ++++++++++++++++++ 2 files changed, 441 insertions(+) create mode 100644 tradein-mvp/backend/tests/test_geocoder_city_substitution.py diff --git a/tradein-mvp/backend/app/services/geocoder.py b/tradein-mvp/backend/app/services/geocoder.py index f3cdb12e..7785116a 100644 --- a/tradein-mvp/backend/app/services/geocoder.py +++ b/tradein-mvp/backend/app/services/geocoder.py @@ -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: diff --git a/tradein-mvp/backend/tests/test_geocoder_city_substitution.py b/tradein-mvp/backend/tests/test_geocoder_city_substitution.py new file mode 100644 index 00000000..b6626d94 --- /dev/null +++ b/tradein-mvp/backend/tests/test_geocoder_city_substitution.py @@ -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"