From 61b329e724acdbb39813820294f0e3a3287e6bdc Mon Sep 17 00:00:00 2001 From: bot-backend Date: Fri, 31 Jul 2026 18:36:12 +0300 Subject: [PATCH 1/2] =?UTF-8?q?fix(tradein/geocoder):=20fail-closed=20?= =?UTF-8?q?=D0=B2=D1=8B=D0=B1=D0=BE=D1=80=20=D0=95=D0=9A=D0=91-=D1=80?= =?UTF-8?q?=D0=B5=D0=B5=D1=81=D1=82=D1=80=D0=BE=D0=B2=20=D0=B2=D0=BC=D0=B5?= =?UTF-8?q?=D1=81=D1=82=D0=BE=20=D1=81=D0=BF=D0=B8=D1=81=D0=BA=D0=B0=20?= =?UTF-8?q?=D0=B3=D0=BE=D1=80=D0=BE=D0=B4=D0=BE=D0=B2=20(#2582)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EKB-only локальные тиры (geoportal/cad_buildings) держались на списке из 37 городов области: "нет в списке -> считаем Екатеринбургом". Молча ломало любой адрес вне списка, включая ДРУГИЕ РЕГИОНЫ РФ целиком -- подтверждено живыми случаями в geocode_cache (Ялта/Крым, Трёхгорный/Челябинская обл., пос. Лёвиха, Димитровград/Ульяновская обл. -- все получили координаты ЕКБ). Новый гейт `_ekb_local_tiers_allowed`: локальные тиры разрешены только если Екатеринбург подтверждён явно, ЛИБО в адресе вообще не назван город/регион (bare "Ленина 1" -- основной трафик формы оценки, дефолт ЕКБ оправдан и сохранён без изменений). Если назван ЛЮБОЙ другой топоним -- известный город списка (`_names_non_ekb_city`, без изменений) или незнакомый (`_names_unrecognized_locality` -- новый структурный маркер "обл./край/ республика/г./пос./с.", БЕЗ "д." -- коллизия с house-маркером "д. 5") -- гейт закрыт, идём во внешние oblast/nation-aware провайдеры. city_hint-логика #2580 не тронута (тот же файл, сосед по цели). --- tradein-mvp/backend/app/services/geocoder.py | 87 +++++++++-- .../backend/tests/test_geocoder_bbox.py | 64 +++++++++ .../tests/test_geocoder_cadastral_matcher.py | 135 ++++++++++++++++++ 3 files changed, 276 insertions(+), 10 deletions(-) diff --git a/tradein-mvp/backend/app/services/geocoder.py b/tradein-mvp/backend/app/services/geocoder.py index 1bf0817f..0a5d8ebf 100644 --- a/tradein-mvp/backend/app/services/geocoder.py +++ b/tradein-mvp/backend/app/services/geocoder.py @@ -231,6 +231,68 @@ def _names_non_ekb_city(address: str) -> bool: return False +# ── Fail-closed EKB-local-tiers gate (#2582) ───────────────────────────────── +# `_names_non_ekb_city` выше ловит только 37 ПЕРЕЧИСЛЕННЫХ городов области — +# любой другой топоним (другой регион РФ целиком, ИЛИ областной город вне +# списка: Реж/Арамиль/Рефтинский/Лёвиха) молча проходил как «не назван», и +# EKB-only локальные тиры (geoportal/cad_buildings) снапали улицу+дом в ЕКБ по +# коллизии (см. живые случаи в geocode_cache: «республика крым, ялта, ...» → +# координаты ЕКБ, «челябинская обл., трёхгорный, ...» → координаты ЕКБ). +# +# Вместо перечисления (сама причина бага — список никогда не покроет все +# города РФ) используем общий структурный признак «в адресе назван +# город/регион»: маркер-слово, которое НИКОГДА не относится к улице/дому +# («обл.», «край», «республика», «г.», «пос.», «с.»). "д." сюда намеренно НЕ +# входит — это маркер дома (`_HOUSE_MARKER`: «ул. Ленина, д. 5» — основной +# формат ввода номера дома), включение сломало бы гейт почти на каждом адресе. +_LOCALITY_MARKER_RE = re.compile( + r"\b(?:обл\.?|область|край|республика|респ\.?|г|пос\.?|посёлок|поселок|с\.)\b" +) + + +def _names_unrecognized_locality(address: str) -> bool: + """True если адрес называет топоним, но это не Екатеринбург и не город из + известного списка `SVERDLOVSK_OBLAST_CITIES` (#2582). + + Отличает «город не назван» (bare «Ленина 1» — так приходит основной + трафик формы оценки; geoportal резолвит его как ЕКБ и это оправданный + дефолт) от «назван НЕЗНАКОМЫЙ город» (Ялта/Трёхгорный/Реж/Лёвиха — + структурный маркер топонима есть, но это не ЕКБ и не известный город + региона 66) — только во втором случае EKB-only локальные тиры нельзя + пускать. + """ + normalized = " ".join(address.lower().split()) + if _EKATERINBURG_RE.search(normalized): + return False # явный ЕКБ — приоритет, см. _names_non_ekb_city + return bool(_LOCALITY_MARKER_RE.search(normalized)) + + +def _ekb_local_tiers_allowed(address: str) -> bool: + """Fail-closed гейт локальных ЕКБ-тиров geocoder (`geocode()`/`suggest()`, #2582). + + ЕКБ-only локальные реестры (geoportal/cad_buildings) используются ТОЛЬКО + когда Екатеринбург подтверждён явно, или в адресе вообще не назван + никакой город/регион. Если назван ЛЮБОЙ другой топоним — известный город + региона (`_names_non_ekb_city`) или незнакомый (`_names_unrecognized_locality`, + маркер структуры адреса) — гейт закрыт, идём во внешние oblast/nation-aware + провайдеры (Yandex/Nominatim, оба с region cross-check и bbox-фильтром по + всей области/стране — им незнакомый топоним не страшен). + + До #2582 решение принималось от противного: «нет в списке 37 городов → + считаем Екатеринбургом» — молча ломало любой адрес вне списка, включая + другие регионы РФ целиком (см. docstring `_names_unrecognized_locality`). + + Explicit-Екатеринбург проверяется отдельно (не только внутри вложенных + хелперов) — это единственный сигнал, который обязан перевешивать ЛЮБОЙ + маркер топонима (напр. «г. Екатеринбург» сам содержит маркер «г»). + """ + if _EKATERINBURG_RE.search(" ".join(address.lower().split())): + return True # явный ЕКБ — приоритет над любым другим топонимом/маркером + if _names_non_ekb_city(address) or _names_unrecognized_locality(address): + return False + return True # города/региона не названо вообще — основной трафик, дефолт ЕКБ + + # ── Address normalisation ─────────────────────────────────────────────────── def normalize_address(address: str) -> str: """Нормализация для cache lookup: lowercase + trim + collapse whitespace. @@ -1099,11 +1161,12 @@ async def suggest(query: str, db: Session | None = None, limit: int = 8) -> list return [] # Tier 1: cadastral FDW (если db доступна) — самый быстрый, без внешних запросов. - # EKB-only (см. `geocode()` #11) — пропускаем, если query явно называет - # другой город области, иначе не-ЕКБ автокомплит может всплыть ЕКБ-домом - # с совпадающими улица+дом. Внешние тиры (2/3/4 ниже) не гейтим — они уже - # oblast-aware. - if db is not None and not _names_non_ekb_city(query): + # EKB-only fail-closed гейт (#2582, было #11) — пропускаем, если query + # явно называет другой топоним (известный город области ИЛИ незнакомый — + # см. `_ekb_local_tiers_allowed`), иначе не-ЕКБ автокомплит может всплыть + # ЕКБ-домом с совпадающими улица+дом. Внешние тиры (2/3/4 ниже) не гейтим — + # они уже oblast-aware. + if db is not None and _ekb_local_tiers_allowed(query): # 1a. Anchored house-match: парсим street+house → точный матч по дом-маркеру. # Решает кейс «Серова 27» где raw-ILIKE по readable_address давал 0 hits. parsed = _parse_street_house(query.strip()) @@ -1170,11 +1233,15 @@ async def geocode(address: str, db: Session) -> GeocodeResult | None: # 2. Локальные источники по street+house (без внешнего API). parsed = _parse_street_house(address.strip()) - # #11: EKB-only локальные тиры (geoportal/cadastral) не должны отвечать на - # адрес другого города области — иначе улица+дом, коллизящие с ЕКБ-домом - # (напр. "проспект Ленина 1" есть и в Нижнем Тагиле, и в ЕКБ), снапаются в - # ЕКБ. Пропускаем сразу к oblast-aware внешним провайдерам ниже (3/4). - use_local_ekb = not _names_non_ekb_city(address) + # #2582 (было #11): EKB-only локальные тиры (geoportal/cadastral) не должны + # отвечать на адрес другого города/региона — иначе улица+дом, коллизящие с + # ЕКБ-домом (напр. "проспект Ленина 1" есть и в Нижнем Тагиле, и в ЕКБ), + # снапаются в ЕКБ. Fail-closed: разрешаем локальные тиры ТОЛЬКО если + # Екатеринбург подтверждён явно или город/регион вообще не назван — см. + # `_ekb_local_tiers_allowed`. Раньше решение принималось от противного + # (список из 37 городов — «нет в списке → считаем ЕКБ»), из-за чего ЛЮБОЙ + # другой регион РФ (Ялта, Трёхгорный) молча резолвился в координаты ЕКБ. + use_local_ekb = _ekb_local_tiers_allowed(address) # 2a. Геопортал ЕКБ — ПЕРВЫЙ локальный tier (полнее cad_buildings ~на 70%). if use_local_ekb and parsed is not None: diff --git a/tradein-mvp/backend/tests/test_geocoder_bbox.py b/tradein-mvp/backend/tests/test_geocoder_bbox.py index b0afa8ed..bd2750ae 100644 --- a/tradein-mvp/backend/tests/test_geocoder_bbox.py +++ b/tradein-mvp/backend/tests/test_geocoder_bbox.py @@ -21,8 +21,10 @@ from app.services.geocoder import ( EKB_BBOX_TIGHT, EKB_BBOX_WIDE, OBLAST66_BBOX, + _ekb_local_tiers_allowed, _has_oblast_marker, _names_non_ekb_city, + _names_unrecognized_locality, _nominatim_query, _yandex_lookup, is_within_ekb_bbox, @@ -202,6 +204,68 @@ def test_names_non_ekb_city(address: str, expected: bool) -> None: assert _names_non_ekb_city(address) is expected +# ── _names_unrecognized_locality / _ekb_local_tiers_allowed (#2582) ───────── +# Fail-closed гейт: EKB-only локальные тиры (geoportal/cad_buildings) больше +# НЕ держатся на списке из 37 городов ("нет в списке → считаем ЕКБ"). Живые +# прод-случаи из geocode_cache (все получили координаты ЕКБ по ошибке до +# фикса): Ялта (другой регион), Трёхгорный (другой регион), Лёвиха/Реж +# (реальные города Свердловской области ВНЕ списка 37). + + +@pytest.mark.parametrize( + "address,expected", + [ + # Живые прод-случаи (#2582) — региональный/settlement маркер есть, + # но это не Екатеринбург и не город из известного списка: + ("республика крым, ялта, ул. репина, 18с2", True), + ("челябинская обл., трёхгорный, ул. ленина, 15", True), + ("свердловская обл., кировградский р-н, пос. лёвиха, ул. карла маркса, 8", True), + ("свердловская обл., реж, ул. ленина, 5", True), + ("ульяновская обл., димитровград, ул. менделеева, 11", True), + # известный город списка ловится отдельно (_names_non_ekb_city), + # эта функция на него не обязана реагировать без маркера: + ("нижний тагил, ленина 10", False), + # города/региона вообще не названо — основной трафик, не топоним: + ("малышева 30", False), + ("серова 27", False), # word-boundary — не город "серов" + ("проспект ленина, 1", False), + # явный Екатеринбург побеждает даже при наличии маркера "г": + ("г. екатеринбург, ул. малышева, 30", False), + ("г екатеринбург, ленинский р-н, ул серова, д 35", False), + ], +) +def test_names_unrecognized_locality(address: str, expected: bool) -> None: + assert _names_unrecognized_locality(address) is expected + + +@pytest.mark.parametrize( + "address,expected", + [ + # Живые прод-случаи (#2582) — гейт обязан закрыться: + ("республика крым, ялта, ул. репина, 18с2", False), + ("челябинская обл., трёхгорный, ул. ленина, 15", False), + ("свердловская обл., кировградский р-н, пос. лёвиха, ул. карла маркса, 8", False), + ("свердловская обл., реж, ул. ленина, 5", False), + # известный город региона 66 (список) — тоже закрыт: + ("Нижний Тагил, проспект Ленина, 1", False), + ("Каменск-Уральский, ул. Ленина 1", False), + # ЕКБ явно назван — гейт открыт (regardless маркера "г"): + ("г. Екатеринбург, ул. Малышева, 30", True), + ("620144, Свердловская обл, г Екатеринбург, Ленинский р-н, ул Серова, д 35", True), + # города/региона вообще не названо — ОСНОВНОЙ ТРАФИК (форма оценки без + # города) — дефолт ЕКБ должен остаться рабочим, иначе деградация #1: + ("Малышева 30", True), + ("Серова 27", True), + ("Космонавтов 7б", True), + ("проспект Ленина, 1", True), + # мкр-исключение (район внутри ЕКБ, не ЗАТО Заречный) — гейт открыт: + ("мкр Заречный, ул. Ленина 5", True), + ], +) +def test_ekb_local_tiers_allowed(address: str, expected: bool) -> None: + assert _ekb_local_tiers_allowed(address) is expected + + # ── Accept-site two-pass tie-break + region cross-check ───────────────────── diff --git a/tradein-mvp/backend/tests/test_geocoder_cadastral_matcher.py b/tradein-mvp/backend/tests/test_geocoder_cadastral_matcher.py index c5d407fc..7bd013af 100644 --- a/tradein-mvp/backend/tests/test_geocoder_cadastral_matcher.py +++ b/tradein-mvp/backend/tests/test_geocoder_cadastral_matcher.py @@ -309,6 +309,98 @@ async def test_geocode_skips_ekb_local_tiers_for_non_ekb_city() -> None: mock_nominatim.assert_called_once() +# ── geocode() fail-closed gate for UNRECOGNIZED locality (#2582) ──────────── +# Живые прод-случаи из geocode_cache: адрес называет город/регион ВНЕ списка +# из 37 (или другой регион РФ целиком) — до фикса `_names_non_ekb_city` +# молчаливо считал это «город не назван» → EKB-only тиры отвечали первыми и +# снапали улицу+дом в координаты ЕКБ. + + +@pytest.mark.parametrize( + ("address", "label"), + [ + ("республика крым, ялта, ул. репина, 18с2", "Ялта (другой регион)"), + ("челябинская обл., трёхгорный, ул. ленина, 15", "Трёхгорный (другой регион)"), + ( + "свердловская обл., кировградский р-н, пос. лёвиха, ул. карла маркса, 8", + "Лёвиха (город области ВНЕ списка 37)", + ), + ("свердловская обл., реж, ул. ленина, 5", "Реж (город области ВНЕ списка 37)"), + ], +) +async def test_geocode_skips_ekb_local_tiers_for_unrecognized_locality( + address: str, label: str +) -> None: + """EKB-only локальные тиры (geoportal/cadastral) НЕ вызываются для адреса, + называющего незнакомый топоним — идём сразу к внешнему oblast/nation-aware + провайдеру (#2582).""" + db = MagicMock() + nominatim_result = GeocodeResult( + lat=45.0, + lon=34.0, + full_address=address, + provider="nominatim", + confidence="approximate", + ) + + with ( + patch("app.services.geocoder._cache_get", return_value=None), + patch("app.services.geocoder._geoportal_house_match") as mock_geoportal, + patch("app.services.geocoder._cadastral_house_match") as mock_house, + patch("app.services.geocoder._cadastral_forward_sync") as mock_forward, + patch("app.services.geocoder._cache_put"), + patch("app.services.geocoder.settings") as mock_settings, + patch( + "app.services.geocoder._nominatim_lookup", + new_callable=AsyncMock, + return_value=nominatim_result, + ) as mock_nominatim, + ): + mock_settings.yandex_geocoder_api_key = None + result = await geocode(address, db) + + assert result is not None, label + mock_geoportal.assert_not_called() + mock_house.assert_not_called() + mock_forward.assert_not_called() + mock_nominatim.assert_called_once() + + +@pytest.mark.parametrize( + ("address", "label"), + [ + ("Серова 27", "ЕКБ без города (основной трафик формы оценки)"), + ("Малышева 30", "ЕКБ без города (основной трафик формы оценки)"), + ("г. Екатеринбург, ул. Серова, д. 27", "ЕКБ с явным городом"), + ], +) +async def test_geocode_still_uses_ekb_local_tiers_for_real_ekb_address( + address: str, label: str +) -> None: + """Регрессия: реальный ЕКБ-адрес (с городом и без) по-прежнему резолвится + через geoportal (fast-path) — fail-closed гейт не должен деградировать + основной трафик (#2582).""" + db = MagicMock() + hit = GeocodeSuggestion( + label="ул. Серова, д. 27, Екатеринбург", + full_address="ул. Серова, д. 27, Екатеринбург", + lat=56.81188, + lon=60.59739, + kind="house", + ) + + with ( + patch("app.services.geocoder._cache_get", return_value=None), + patch("app.services.geocoder._geoportal_house_match", return_value=hit) as mock_geoportal, + patch("app.services.geocoder._cache_put"), + ): + result = await geocode(address, db) + + assert result is not None, label + assert result.lat == pytest.approx(56.81188), label + mock_geoportal.assert_called_once() + + # ── suggest() wiring ───────────────────────────────────────────────────────── @@ -409,3 +501,46 @@ async def test_suggest_skips_ekb_local_tier_for_non_ekb_city() -> None: mock_house.assert_not_called() mock_forward.assert_not_called() mock_nominatim.assert_called_once() + + +@pytest.mark.parametrize( + ("query", "label"), + [ + ("республика крым, ялта, ул. репина, 18с2", "Ялта (другой регион)"), + ("свердловская обл., реж, ул. ленина, 5", "Реж (город области ВНЕ списка 37)"), + ], +) +async def test_suggest_skips_ekb_local_tier_for_unrecognized_locality( + query: str, label: str +) -> None: + """Незнакомый топоним (не в списке 37, не Екатеринбург) → EKB-only + cadastral Tier 1 не вызывается; falls through к внешним тирам (#2582).""" + db = MagicMock() + nominatim_hit = [ + GeocodeSuggestion( + label="ул. Репина, 18с2", + full_address=query, + lat=44.5, + lon=34.16, + kind="house", + ) + ] + + with ( + patch("app.services.geocoder._cadastral_house_match") as mock_house, + patch("app.services.geocoder._cadastral_forward_sync") as mock_forward, + patch("app.services.geocoder.settings") as mock_settings, + patch( + "app.services.geocoder._nominatim_suggest", + new_callable=AsyncMock, + return_value=nominatim_hit, + ) as mock_nominatim, + ): + mock_settings.dadata_api_token = None + mock_settings.yandex_geocoder_api_key = None + results = await suggest(query, db=db, limit=8) + + assert len(results) == 1, label + mock_house.assert_not_called() + mock_forward.assert_not_called() + mock_nominatim.assert_called_once() -- 2.45.3 From af609c18f31b34d85391a99ad6c191292605bbac Mon Sep 17 00:00:00 2001 From: bot-backend Date: Fri, 31 Jul 2026 19:17:47 +0300 Subject: [PATCH 2/2] =?UTF-8?q?fix(tradein/geocoder):=20=D0=BF=D0=BE=D1=87?= =?UTF-8?q?=D0=B8=D0=BD=D0=B8=D1=82=D1=8C=20=D0=BC=D0=B0=D1=80=D0=BA=D0=B5?= =?UTF-8?q?=D1=80=20=D1=81=D0=B5=D0=BB=D0=B0=20+=20fail-closed=20=D0=B4?= =?UTF-8?q?=D0=BB=D1=8F=20=D1=8F=D0=B2=D0=BD=D0=BE=D0=B3=D0=BE=20city=5Fhi?= =?UTF-8?q?nt=20(#2582)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit H1 (deep-review PR #2589): `с\.` в _LOCALITY_MARKER_RE был мертворождён — хвостовой \b не может сработать между "." и пробелом (оба не-словесные), формат «с. <село>» (104/77к прод-адресов) не ловился НИ РАЗУ, зато ложно срабатывал на инициалах «им с.п.королева». Живой инцидент воспроизведён: "с. Новоалексеевское, ул. 8 Марта,37" снапалось в ЕКБ (совпадение до 5-го знака, село в 35 км западнее). Заменил на lookahead-форму для однобуквенных маркеров (с/д/дер/пгт/ст): "\.\s*(?=[а-яё])" — различает "д. 5" (дом-номер, не матчит) от "д. Кашино" (деревня, матчит), что заодно позволило безопасно включить "д."/"дер." (раньше исключённые из-за коллизии с house-маркером). M2: гейт по city_hint оставался list-based (_names_non_ekb_city(city_hint)) — для СТРУКТУРНОГО хинта (не свободного текста) fail-closed форма должна быть обратной: "не Екатеринбург → закрыто", а не "есть в списке 37 → закрыто". city_hint принимает произвольную строку без валидации (API напрямую, минуя 6-городный UI-selector) — city_hint="Реж" держал гейт открытым, geoportal отдавал точный ЕКБ-дом, city_ambiguous=False делало это уверенно-неверным. M3: добавлены изолирующие тесты — предыдущая параметризация "незнакомый топоним" всегда несла ещё и "обл."/"республика"/"край" одновременно с проверяемым сигналом, поэтому мёртвый "с\." прошёл незамеченным. Плюс честно зафиксирована известная дыра (гейт по city_hint) — "реж, ленина 5" без маркера остаётся в ЕКБ-дефолте (архитектурное решение отдельно, не список городов). docstring `_names_unrecognized_locality`/`_ekb_local_tiers_allowed` приведён в соответствие с фактическим поведением regex. --- tradein-mvp/backend/app/services/geocoder.py | 79 ++++++++++++++----- .../backend/tests/test_geocoder_bbox.py | 67 +++++++++++++++- .../tests/test_geocoder_cadastral_matcher.py | 52 ++++++++++++ 3 files changed, 174 insertions(+), 24 deletions(-) diff --git a/tradein-mvp/backend/app/services/geocoder.py b/tradein-mvp/backend/app/services/geocoder.py index 0f450ccc..0a503cca 100644 --- a/tradein-mvp/backend/app/services/geocoder.py +++ b/tradein-mvp/backend/app/services/geocoder.py @@ -295,11 +295,29 @@ def _names_non_ekb_city(address: str) -> bool: # Вместо перечисления (сама причина бага — список никогда не покроет все # города РФ) используем общий структурный признак «в адресе назван # город/регион»: маркер-слово, которое НИКОГДА не относится к улице/дому -# («обл.», «край», «республика», «г.», «пос.», «с.»). "д." сюда намеренно НЕ -# входит — это маркер дома (`_HOUSE_MARKER`: «ул. Ленина, д. 5» — основной -# формат ввода номера дома), включение сломало бы гейт почти на каждом адресе. +# («обл.», «край», «республика», «г.», «пос.»). +# +# Два разных класса маркеров — разная форма regex: +# 1. Маркеры БЕЗ обязательной точки после (обл/область/край/республика/респ/ +# г/пос/посёлок/поселок) — сохраняют старую форму `\bслово\.?\b`. Точка +# опциональна и есть трейлинг `\b`, который для этих слов срабатывает и +# без точки (следующий символ — пробел/запятая, не-словесный). +# 2. Маркеры-ОДНОБУКВЕННЫЕ сокращения, где точка ОБЯЗАТЕЛЬНА для отличия от +# других смыслов той же буквы («с» — типографский мусор/что угодно, «д» — +# дом-маркер `_HOUSE_MARKER`) — «с.», «д.», «дер.», «пгт.», «ст.». Тут +# хвостовой `\b` В ПРИНЦИПЕ не может сработать после точки перед пробелом +# (оба не-словесные — на стыке `.` и ` ` нет границы \w/не-\w) — было +# смерто-рождённой формой, вылавливавшей 0 из 104 прод-адресов «с. <село>» +# (deep-review PR #2589, H1). Вместо `\b` в конце — lookahead на кириллицу +# сразу после точки (с опциональными пробелами): `\.\s*(?=[а-яё])`. +# Это и различает «д. 5» (дом-маркер, номер, НЕ топоним — после точки +# цифра, lookahead не матчит) от «д. Кашино» (деревня — после точки буква, +# матчит) БЕЗ явного исключения "д." из списка: раньше "д." исключали +# целиком, потому что не было способа отличить дом от деревни — lookahead +# даёт этот способ, поэтому "д."/"дер." теперь можно включить безопасно. _LOCALITY_MARKER_RE = re.compile( - r"\b(?:обл\.?|область|край|республика|респ\.?|г|пос\.?|посёлок|поселок|с\.)\b" + r"\b(?:обл\.?|область|край|республика|респ\.?|г|пос\.?|посёлок|поселок)\b" + r"|\b(?:с|д|дер|пгт|ст)\.\s*(?=[а-яё])" ) @@ -309,10 +327,18 @@ def _names_unrecognized_locality(address: str) -> bool: Отличает «город не назван» (bare «Ленина 1» — так приходит основной трафик формы оценки; geoportal резолвит его как ЕКБ и это оправданный - дефолт) от «назван НЕЗНАКОМЫЙ город» (Ялта/Трёхгорный/Реж/Лёвиха — - структурный маркер топонима есть, но это не ЕКБ и не известный город - региона 66) — только во втором случае EKB-only локальные тиры нельзя - пускать. + дефолт) от «назван НЕЗНАКОМЫЙ город» (Ялта/Трёхгорный/Лёвиха, «с. Кашино», + «д. Родина» — структурный маркер топонима есть, но это не ЕКБ и не + известный город региона 66) — только во втором случае EKB-only локальные + тиры нельзя пускать. + + Известная дыра (сознательно, не «баг» — закрывать architectural-решением + отдельно, не перечислением городов): голый «Реж, Ленина 5» / «Арамиль, + Мира 1» БЕЗ структурного маркера («обл.»/«г.»/«с.» и т.п.) и без города + из списка `SVERDLOVSK_OBLAST_CITIES` не ловится — падает в «город не + назван» и уходит в ЕКБ-дефолт. Все живые прод-подмены (geocode_cache) + были с маркером/региональным префиксом — см. тесты `test_ekb_local_tiers_allowed` + (изолирующий кейс на голое «реж, ленина 5» зафиксирован как известная дыра). """ normalized = " ".join(address.lower().split()) if _EKATERINBURG_RE.search(normalized): @@ -330,15 +356,25 @@ def _ekb_local_tiers_allowed(address: str, city_hint: str | None = None) -> bool - известный город региона 66 в ТЕКСТЕ адреса (`_names_non_ekb_city`, без изменений, #11); - - `city_hint` называет известный город региона 66, отличный от ЕКБ — - #2580 (C1): без этой проверки `city_hint` мёртв для локальных тиров — - caller передал, например, "Нижний Тагил" для текста без города, но - всё равно получал бы "точный" ЕКБ-хит с `city_ambiguous=False` (хинт - ведь был!) — ложно-уверенный неверный результат; + - `city_hint` ЗАДАН и НЕ является Екатеринбургом (`hint_blocks` ниже) — + #2580 (C1) + #2589 (deep-review M2): `city_hint` — явный СТРУКТУРНЫЙ + вход от caller'а (не свободный текст адреса), поэтому для него + fail-closed форма ОБРАТНАЯ по сравнению с текстом адреса: «не + Екатеринбург → закрыто», а не «есть в списке 37 → закрыто». Список + 37 годился бы, только если единственный риск — известные города; но + `city_hint` принимает произвольную строку без валидации (в т.ч. через + API напрямую, минуя 6-городный selector UI) — «Реж»/любой седьмой + город, добавленный в реестр, до сих пор не в списке 37 → список-based + проверка держала гейт открытым, geoportal отдавал точный ЕКБ-дом, а + `city_ambiguous=False` (хинт ведь БЫЛ) делало это уверенно-неверным — + худший случай (без hint был бы честный `city_ambiguous=True`); - незнакомый топоним в ТЕКСТЕ адреса — структурный маркер - (`_names_unrecognized_locality`, #2582: «обл./край/республика/г./пос./ - с.») — покрывает города вне списка 37 (Реж/Лёвиха) и ДРУГИЕ РЕГИОНЫ РФ - целиком (Ялта, Трёхгорный), не требуя их перечислять. + (`_names_unrecognized_locality`, #2582: «обл./край/республика/г./пос.» + и однобуквенные «с./д./дер./пгт./ст.» с lookahead на кириллицу после + точки) — покрывает города вне списка 37 С маркером (Лёвиха, «с. + Новоалексеевское») и ДРУГИЕ РЕГИОНЫ РФ целиком (Ялта, Трёхгорный), не + требуя их перечислять. Голый город без маркера и вне списка (Реж без + «обл.») — известная дыра, см. `_names_unrecognized_locality`. До #2582 решение по третьему пункту принималось от противного: «нет в списке 37 городов → считаем Екатеринбургом» — молча ломало любой адрес @@ -348,13 +384,14 @@ def _ekb_local_tiers_allowed(address: str, city_hint: str | None = None) -> bool `city_hint`, противоречащий явному "Екатеринбург" в тексте адреса (`_names_non_ekb_city(address)` для такого текста вернёт False и не закроет гейт сам по себе — Екатеринбург в тексте не мешает), тем не менее - ЗАКРОЕТ гейт через `hint_names_non_ekb` — тот же trade-off, что уже был - одобрен в #2580 (`use_local_ekb`), сюда не переписываем. Нормальный кейс — - caller не передаёт противоречащие address и city_hint одновременно. + ЗАКРОЕТ гейт через `hint_blocks` — тот же trade-off, что уже был одобрен + в #2580 (`use_local_ekb`), сюда не переписываем. Нормальный кейс — caller + не передаёт противоречащие address и city_hint одновременно. """ - hint_names_non_ekb = bool(city_hint) and _names_non_ekb_city(city_hint) + hint = " ".join((city_hint or "").lower().split()) + hint_blocks = bool(hint) and not _EKATERINBURG_RE.search(hint) return not ( - _names_non_ekb_city(address) or hint_names_non_ekb or _names_unrecognized_locality(address) + _names_non_ekb_city(address) or hint_blocks or _names_unrecognized_locality(address) ) diff --git a/tradein-mvp/backend/tests/test_geocoder_bbox.py b/tradein-mvp/backend/tests/test_geocoder_bbox.py index f5d49d3b..eacc7d3a 100644 --- a/tradein-mvp/backend/tests/test_geocoder_bbox.py +++ b/tradein-mvp/backend/tests/test_geocoder_bbox.py @@ -18,6 +18,7 @@ import httpx import pytest from app.services.geocoder import ( + _LOCALITY_MARKER_RE, EKB_BBOX_TIGHT, EKB_BBOX_WIDE, OBLAST66_BBOX, @@ -232,12 +233,57 @@ def test_names_non_ekb_city(address: str, expected: bool) -> None: # явный Екатеринбург побеждает даже при наличии маркера "г": ("г. екатеринбург, ул. малышева, 30", False), ("г екатеринбург, ленинский р-н, ул серова, д 35", False), + # ── Изолирующие кейсы (#2589 M3) — параметризация выше зелёная и на + # маркер, и на «обл./республика/край» одновременно (все реальные + # прод-адреса их несут вместе); ниже — маркер БЕЗ регионального + # префикса, чтобы поймать регрессию именно сигнала-маркера (это и + # проехало у H1: мёртвый «с\.» был бы незамечен без таких кейсов). + ( + "с. новоалексеевское, ул. 8 марта,37", + True, + ), # живой инцидент (#2589 H1) — «с.» БЕЗ доп. маркера + ("с. кашино, ул. ленина, 5", True), # изолированный «с.» (M3) + ("д. родина, ул. мира, 1", True), # изолированный «д.» (M3, lookahead-фикс) + ("пгт. шаля, ул. советская, 2", True), # изолированный «пгт.» (M3) + ( + "пос. мичуринский, широкореченская ул., 45", + True, + ), # пригородный посёлок ЕКБ — маркер закрывает гейт (M3, честно + # закреплено: даже для формально «своих» посёлков без «Екатеринбург» + # в тексте фолбэк на oblast-aware внешние провайдеры — не регрессия + # координат, просто не через fast-path geoportal) + ("реж, ленина 5", False), # ИЗВЕСТНАЯ ДЫРА (#2589 M3): голый город вне + # списка 37 БЕЗ маркера — не ловится, уходит в ЕКБ-дефолт. Честно + # зафиксировано, не «фиксим» здесь (см. docstring — architectural + # follow-up отдельно, не через список городов). + # house-маркер НЕ должен матчить как топоним (регрессия #2582→#2589): + ("ул. ленина, д. 5", False), + ("д. 5", False), + ("д. 5 стр. 2", False), ], ) def test_names_unrecognized_locality(address: str, expected: bool) -> None: assert _names_unrecognized_locality(address) is expected +@pytest.mark.parametrize( + "text,expected", + [ + # Ровно набор из deep-review (#2589 H1) — «Проверь сам на живом Python». + ("д. 5", False), + ("д. 5 стр. 2", False), + ("ул. ленина, д. 5", False), + ("с. кашино", True), + ("д. родина", True), + ("пгт. шаля", True), + ], +) +def test_locality_marker_re_isolated(text: str, expected: bool) -> None: + """Regex-уровень, изолированно от `_names_unrecognized_locality`'s + Екатеринбург-early-return — прямая проверка `_LOCALITY_MARKER_RE` (#2589 M3/H1).""" + assert bool(_LOCALITY_MARKER_RE.search(text)) is expected + + @pytest.mark.parametrize( "address,expected", [ @@ -260,16 +306,24 @@ def test_names_unrecognized_locality(address: str, expected: bool) -> None: ("проспект Ленина, 1", True), # мкр-исключение (район внутри ЕКБ, не ЗАТО Заречный) — гейт открыт: ("мкр Заречный, ул. Ленина 5", True), + # изолирующий кейс (#2589 H1/M3) — «с.»-маркер БЕЗ доп. «обл.»/«республика»: + ("с. Новоалексеевское, ул. 8 Марта,37", False), + # house-маркер «д.» НЕ закрывает гейт (регрессия H1-фикса): + ("ул. Ленина, д. 5", True), ], ) def test_ekb_local_tiers_allowed(address: str, expected: bool) -> None: assert _ekb_local_tiers_allowed(address) is expected -# ── _ekb_local_tiers_allowed × city_hint (#2580 merge, #2582) ─────────────── +# ── _ekb_local_tiers_allowed × city_hint (#2580 merge, #2582, #2589 M2) ───── # Обе логики независимо закрывают гейт (OR трёх сигналов) — проверяем, что -# ни одна из четырёх веток не стала недостижимой после слияния #2580/#2582: +# ни одна из веток не стала недостижимой после слияния #2580/#2582/#2589-M2: # A. city_hint называет известный город региона 66 (не ЕКБ) → закрыто. +# A2. city_hint — ЛЮБАЯ непустая строка, отличная от "Екатеринбург" (в т.ч. +# НЕ из списка 37 — #2589 M2, было главной дырой: `city_hint="Реж"` +# раньше держал гейт открытым, geoportal отдавал точный ЕКБ-дом с +# `city_ambiguous=False` — уверенно-неверный результат) → закрыто. # B. незнакомый топоним в ТЕКСТЕ адреса (маркер) → закрыто. # C. ни того ни другого → открыто (дефолт ЕКБ). # D. явный city_hint="Екатеринбург" (без конфликта в тексте) → открыто. @@ -282,6 +336,12 @@ def test_ekb_local_tiers_allowed(address: str, expected: bool) -> None: # если сам текст адреса города вообще не называет (#2580 C1). ("Ленина, 1", "Нижний Тагил", False, "A: hint=известный не-ЕКБ город"), ("Серова 27", "Каменск-Уральский", False, "A: hint=известный не-ЕКБ город (2)"), + # A2 (#2589 M2 — ключевой регрессионный кейс фикса): hint называет + # город, КОТОРОГО НЕТ в списке 37 (произвольный текст через API, не + # UI-selector) → тоже закрыто. До фикса `_names_non_ekb_city(city_hint)` + # давал False (Реж не в списке) → гейт был открыт. + ("Ленина, 1", "Реж", False, "A2: hint=не-ЕКБ город ВНЕ списка 37"), + ("Серова 27", "Ялта", False, "A2: hint=город другого региона"), # B: незнакомый топоним в тексте (маркер) закрывает гейт независимо # от hint (#2582) — hint здесь не задан, чистая проверка B. ("свердловская обл., реж, ул. ленина, 5", None, False, "B: маркер в тексте"), @@ -289,7 +349,8 @@ def test_ekb_local_tiers_allowed(address: str, expected: bool) -> None: ("Малышева 30", None, True, "C: ничего не названо"), ("Серова 27", "", True, "C: пустой hint == отсутствию hint'а"), # D: явный city_hint="Екатеринбург" без конфликта в тексте → открыто — - # ключевая проверка, что #2580 C1 не сломал дефолтный ЕКБ-путь через hint. + # ключевая проверка, что #2580 C1 / #2589 M2 не сломали дефолтный + # ЕКБ-путь через hint. ("Ленина, 1", "Екатеринбург", True, "D: hint=Екатеринбург"), ("Серова 27", "екатеринбург", True, "D: hint=екатеринбург (регистр)"), ], diff --git a/tradein-mvp/backend/tests/test_geocoder_cadastral_matcher.py b/tradein-mvp/backend/tests/test_geocoder_cadastral_matcher.py index 70707996..3e179c8c 100644 --- a/tradein-mvp/backend/tests/test_geocoder_cadastral_matcher.py +++ b/tradein-mvp/backend/tests/test_geocoder_cadastral_matcher.py @@ -326,6 +326,10 @@ async def test_geocode_skips_ekb_local_tiers_for_non_ekb_city() -> None: "Лёвиха (город области ВНЕ списка 37)", ), ("свердловская обл., реж, ул. ленина, 5", "Реж (город области ВНЕ списка 37)"), + ( + "с. Новоалексеевское, ул. 8 Марта,37", + "Новоалексеевское (живой инцидент #2589 H1 — «с.»-маркер без доп. «обл.»)", + ), ], ) async def test_geocode_skips_ekb_local_tiers_for_unrecognized_locality( @@ -372,6 +376,7 @@ async def test_geocode_skips_ekb_local_tiers_for_unrecognized_locality( ("Серова 27", "ЕКБ без города (основной трафик формы оценки)"), ("Малышева 30", "ЕКБ без города (основной трафик формы оценки)"), ("г. Екатеринбург, ул. Серова, д. 27", "ЕКБ с явным городом"), + ("ул. Серова, д. 27", "house-маркер «д.» не закрывает гейт (#2589 H1 regression)"), ], ) async def test_geocode_still_uses_ekb_local_tiers_for_real_ekb_address( @@ -427,6 +432,53 @@ async def test_geocode_explicit_ekaterinburg_city_hint_still_uses_local_tiers() mock_geoportal.assert_called_once() +async def test_geocode_city_hint_unknown_city_skips_local_ekb_tiers() -> None: + """#2589 M2 (deep-review fix): `city_hint="Реж"` — город ВНЕ списка 37 и + без структурного маркера в самом тексте адреса — обязан закрыть ЕКБ-only + локальные тиры так же, как известный `city_hint`. До фикса + `hint_names_non_ekb = _names_non_ekb_city(city_hint)` был list-based и + держал гейт открытым для ЛЮБОГО города вне списка 37 (Реж, седьмой город + после расширения UI-selector'а, произвольный текст через API) — geoportal + отдавал точный ЕКБ-хит, а `city_ambiguous=False` (хинт ведь БЫЛ) делало + это уверенно-неверным результатом (хуже, чем честный `city_ambiguous=True` + без хинта вообще).""" + db = MagicMock() + ekb_hit = GeocodeSuggestion( + label="Ленина, 1, Екатеринбург", + full_address="Ленина, 1, Екатеринбург", + lat=56.83788, + lon=60.58018, + kind="house", + ) + rezh_result = GeocodeResult( + lat=57.371, lon=61.386, full_address="Ленина, 1, Реж", provider="nominatim" + ) + + with ( + patch("app.services.geocoder._cache_get", return_value=None), + patch("app.services.geocoder._cache_put"), + patch( + "app.services.geocoder._geoportal_house_match", return_value=ekb_hit + ) as mock_geoportal, + patch("app.services.geocoder._cadastral_house_match", return_value=ekb_hit) as mock_house, + patch("app.services.geocoder._cadastral_forward_sync", return_value=[]), + patch("app.services.geocoder.settings") as mock_settings, + patch( + "app.services.geocoder._nominatim_lookup", + new_callable=AsyncMock, + return_value=rezh_result, + ), + ): + mock_settings.yandex_geocoder_api_key = None + result = await geocode("Ленина, 1", db, city_hint="Реж") + + mock_geoportal.assert_not_called() + mock_house.assert_not_called() + assert result is not None + assert result.lat == pytest.approx(57.371) # Реж, НЕ подставленный ЕКБ-хит (56.83788) + assert result.lat != pytest.approx(56.83788) + + # ── suggest() wiring ───────────────────────────────────────────────────────── -- 2.45.3