From 720cee2c8e493bbd96d27ea71cdef3d5848fcfac Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sun, 12 Jul 2026 23:45:40 +0300 Subject: [PATCH 1/3] fix(tradein/geocoder): gate EKB-local tiers for non-EKB oblast addresses (#11) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit geocode() ran EKB-only local tiers (2a _geoportal_house_match on ekb_geoportal_buildings, 2c _cadastral_house_match, 2d _cadastral_forward_sync — all strictly EKB or EKB-dominated) BEFORE the already oblast-aware external providers (Yandex/Nominatim via OBLAST66_BBOX + region cross-check, shipped in c0cbdc2f). _parse_street_house drops the city, so any non-EKB oblast street+house that collides with an EKB building (e.g. "проспект Ленина 1" exists in both Nizhny Tagil and EKB) short-circuited to EKB coordinates. Add _names_non_ekb_city() — reuses the existing SVERDLOVSK_OBLAST_CITIES gazetteer (minus Ekaterinburg) and word-boundary matching from _has_oblast_marker/_DISTRICT_PREFIXES — to detect when an address explicitly names a different oblast city. Gate tiers 2a/2c/2d behind `use_local_ekb = not _names_non_ekb_city(address)`; EKB/bare addresses keep the exact same code path (byte-identical ordering/logic, only additive gating). Non-EKB addresses fall straight through to Yandex/Nominatim, which already handle oblast-wide geocoding correctly. Verified gendesign_cad_buildings is 99.85% EKB-scoped (47043/47111 rows); ekb_geoportal_buildings is 100% EKB by construction — gating loses no meaningful local coverage for non-EKB cities. Follow-up (not in this PR): geocode_cache has rows poisoned by the old behavior (non-EKB addresses cached with EKB coordinates) — needs a purge. --- tradein-mvp/backend/app/services/geocoder.py | 64 +++++++++++++++---- .../backend/tests/test_geocoder_bbox.py | 19 ++++++ .../tests/test_geocoder_cadastral_matcher.py | 45 +++++++++++++ 3 files changed, 114 insertions(+), 14 deletions(-) diff --git a/tradein-mvp/backend/app/services/geocoder.py b/tradein-mvp/backend/app/services/geocoder.py index 49e0c1ff..e3a8653e 100644 --- a/tradein-mvp/backend/app/services/geocoder.py +++ b/tradein-mvp/backend/app/services/geocoder.py @@ -178,6 +178,31 @@ def _has_oblast_marker(text_lower: str) -> bool: return False +# Города региона 66 КРОМЕ Екатеринбурга — используется чтобы отсечь EKB-only +# локальные тиры (geoportal/cadastral, см. `geocode()`) от адреса другого +# города области. re.escape на элементах SVERDLOVSK_OBLAST_CITIES-{ekb}. +_NON_EKB_OBLAST_CITIES = SVERDLOVSK_OBLAST_CITIES - {"екатеринбург"} +_NON_EKB_CITY_RE = re.compile( + r"\b(?:" + "|".join(re.escape(c) for c in _NON_EKB_OBLAST_CITIES) + r")\b" +) + + +def _names_non_ekb_city(address: str) -> bool: + """True если адрес явно называет город Свердловской области, ОТЛИЧНЫЙ от + Екатеринбурга (по границе слова, с исключением district-приставок «мкр/р-н + <город>»). Используется чтобы НЕ пускать EKB-only локальные тиры + (geoportal/cadastral) на не-ЕКБ адрес: улица+дом, совпадающие с ЕКБ-домом + (проспект Ленина 1 есть в обоих городах), иначе адрес снапается в ЕКБ (#11). + """ + normalized = " ".join(address.lower().split()) + for m in _NON_EKB_CITY_RE.finditer(normalized): + prefix_words = normalized[: m.start()].split() + if prefix_words and prefix_words[-1] in _DISTRICT_PREFIXES: + continue # «мкр Заречный» — район внутри другого города, не ЗАТО Заречный + return True + return False + + # ── Address normalisation ─────────────────────────────────────────────────── def normalize_address(address: str) -> str: """Нормализация для cache lookup: lowercase + trim + collapse whitespace. @@ -1113,8 +1138,14 @@ 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) + # 2a. Геопортал ЕКБ — ПЕРВЫЙ локальный tier (полнее cad_buildings ~на 70%). - if parsed is not None: + if use_local_ekb and parsed is not None: street, house = parsed try: hit = await asyncio.to_thread(_geoportal_house_match, db, street, house) @@ -1142,7 +1173,7 @@ async def geocode(address: str, db: Session) -> GeocodeResult | None: # Anchored house-match: парсим street+house → точный матч по дом-маркеру. # raw-ILIKE по полному readable_address давал 0 hits для «Серова 27» / DaData-форм # (литеральная подстрока не совпадает). - if parsed is not None: + if use_local_ekb and parsed is not None: street, house = parsed hit = await asyncio.to_thread(_cadastral_house_match, db, street, house) if hit is not None: @@ -1163,19 +1194,24 @@ async def geocode(address: str, db: Session) -> GeocodeResult | None: return result # 2d. Fallback: legacy raw-ILIKE forward search (для нераспарсенных форм) - cad_suggestions = await asyncio.to_thread(_cadastral_forward_sync, db, address.strip(), limit=1) - if cad_suggestions: - s = cad_suggestions[0] - result = GeocodeResult( - lat=s.lat, - lon=s.lon, - full_address=s.full_address, - provider="nominatim", # treat as "local" — same confidence as nominatim - confidence="exact", + if use_local_ekb: + cad_suggestions = await asyncio.to_thread( + _cadastral_forward_sync, db, address.strip(), limit=1 ) - await asyncio.to_thread(_cache_put, db, addr_norm, result) - logger.info("geocode cadastral fdw: %s → (%.5f, %.5f)", addr_norm, result.lat, result.lon) - return result + if cad_suggestions: + s = cad_suggestions[0] + result = GeocodeResult( + lat=s.lat, + lon=s.lon, + full_address=s.full_address, + provider="nominatim", # treat as "local" — same confidence as nominatim + confidence="exact", + ) + await asyncio.to_thread(_cache_put, db, addr_norm, result) + logger.info( + "geocode cadastral fdw: %s → (%.5f, %.5f)", addr_norm, result.lat, result.lon + ) + return result # 3. Yandex (если есть key) с typo-fallback if settings.yandex_geocoder_api_key: diff --git a/tradein-mvp/backend/tests/test_geocoder_bbox.py b/tradein-mvp/backend/tests/test_geocoder_bbox.py index ac5bf3e1..6c575fc9 100644 --- a/tradein-mvp/backend/tests/test_geocoder_bbox.py +++ b/tradein-mvp/backend/tests/test_geocoder_bbox.py @@ -22,6 +22,7 @@ from app.services.geocoder import ( EKB_BBOX_WIDE, OBLAST66_BBOX, _has_oblast_marker, + _names_non_ekb_city, _nominatim_query, _yandex_lookup, is_within_ekb_bbox, @@ -174,6 +175,24 @@ def test_has_oblast_marker(text: str, expected: bool) -> None: assert _has_oblast_marker(text) is expected +# ── _names_non_ekb_city — gate для EKB-only локальных tier'ов (#11) ───────── + + +@pytest.mark.parametrize( + "address,expected", + [ + ("Нижний Тагил, проспект Ленина, 1", True), + ("г. Екатеринбург, проспект Ленина, 1", False), + ("проспект Ленина, 1", False), + ("Серова 27", False), # word-boundary — НЕ город "Серов" + ("мкр Заречный, ул. Ленина 5", False), # район внутри города, не ЗАТО Заречный + ("Каменск-Уральский, ул. Ленина 1", True), + ], +) +def test_names_non_ekb_city(address: str, expected: bool) -> None: + assert _names_non_ekb_city(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 33481860..2b3a748f 100644 --- a/tradein-mvp/backend/tests/test_geocoder_cadastral_matcher.py +++ b/tradein-mvp/backend/tests/test_geocoder_cadastral_matcher.py @@ -26,6 +26,7 @@ sys.modules.setdefault("weasyprint", _wp_mock) from app.services.geocoder import ( # noqa: E402 + GeocodeResult, GeocodeSuggestion, _cadastral_house_match, _parse_street_house, @@ -264,6 +265,50 @@ async def test_geocode_skips_house_match_when_parse_fails() -> None: mock_forward.assert_called_once() +# ── geocode() non-EKB city gate (#11) ──────────────────────────────────────── + + +async def test_geocode_skips_ekb_local_tiers_for_non_ekb_city() -> None: + """Non-EKB oblast-адрес (Нижний Тагил) → EKB-only локальные тиры (geoportal/ + cadastral) НЕ вызываются, результат идёт от oblast-aware Nominatim (#11). + + Без гейта «проспект Ленина, 1» коллизирует с одноимённым ЕКБ-домом через + geoportal/cadastral и снапается в ЕКБ вместо Нижнего Тагила. + """ + db = MagicMock() + nominatim_result = GeocodeResult( + lat=57.905, + lon=59.950, + full_address="проспект Ленина, 1, Нижний Тагил", + 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("Нижний Тагил, проспект Ленина, 1", db) + + assert result is not None + assert result.lat == pytest.approx(57.905) + assert result.lon == pytest.approx(59.950) + mock_geoportal.assert_not_called() + mock_house.assert_not_called() + mock_forward.assert_not_called() + mock_nominatim.assert_called_once() + + # ── suggest() wiring ───────────────────────────────────────────────────────── From 2ef3e42da15b8e6d0e19153d532ce1355737d5b6 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sun, 12 Jul 2026 23:50:49 +0300 Subject: [PATCH 2/3] =?UTF-8?q?fix(tradein/geocoder):=20explicit=20=D0=95?= =?UTF-8?q?=D0=BA=D0=B0=D1=82=D0=B5=D1=80=D0=B8=D0=BD=D0=B1=D1=83=D1=80?= =?UTF-8?q?=D0=B3=20wins=20over=20oblast-city=20homonym=20(#11)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prod-cache verification (deep-review) found 3 false-positives: EKB addresses that literally contain "екатеринбург" but also happen to contain a homonym of another oblast city inside a neighborhood/posyolok name — ЖК "Заречный" and пос. Сысерть, both administratively inside Ekaterinburg, not the ZATO Zarechny / town Sysert they share a name with. These were gated off the EKB-only local tiers; if Nominatim were momentarily down, a real EKB address would return None — a latent regression not present before this PR. Add an explicit-city short-circuit at the top of _names_non_ekb_city: if the normalized address contains "екатеринбург", return False immediately (EKB tiers stay on). Cannot affect the true-positive (non-EKB) cases, which never contain "екатеринбург". --- tradein-mvp/backend/app/services/geocoder.py | 10 ++++++++++ tradein-mvp/backend/tests/test_geocoder_bbox.py | 3 +++ 2 files changed, 13 insertions(+) diff --git a/tradein-mvp/backend/app/services/geocoder.py b/tradein-mvp/backend/app/services/geocoder.py index e3a8653e..cdc1178d 100644 --- a/tradein-mvp/backend/app/services/geocoder.py +++ b/tradein-mvp/backend/app/services/geocoder.py @@ -193,8 +193,18 @@ def _names_non_ekb_city(address: str) -> bool: <город>»). Используется чтобы НЕ пускать EKB-only локальные тиры (geoportal/cadastral) на не-ЕКБ адрес: улица+дом, совпадающие с ЕКБ-домом (проспект Ленина 1 есть в обоих городах), иначе адрес снапается в ЕКБ (#11). + + Явное "Екатеринбург" в адресе имеет приоритет над любым омонимом другого + города региона — иначе ЖК/посёлки с названием, совпадающим с другим + городом области (ЖК «Заречный», пос. «Сысерть» внутри ЕКБ), ложно + отсекали бы EKB-only тиры для реального ЕКБ-адреса. """ normalized = " ".join(address.lower().split()) + # Явное "Екатеринбург" в адресе → это ЕКБ, даже если в тексте встречается + # омоним соседнего города (ЖК «Заречный», пос. Сысерть в составе МО «город + # Екатеринбург» и т.п. — реальные false-positive из прод-кэша). + if "екатеринбург" in normalized: + return False for m in _NON_EKB_CITY_RE.finditer(normalized): prefix_words = normalized[: m.start()].split() if prefix_words and prefix_words[-1] in _DISTRICT_PREFIXES: diff --git a/tradein-mvp/backend/tests/test_geocoder_bbox.py b/tradein-mvp/backend/tests/test_geocoder_bbox.py index 6c575fc9..56a7e192 100644 --- a/tradein-mvp/backend/tests/test_geocoder_bbox.py +++ b/tradein-mvp/backend/tests/test_geocoder_bbox.py @@ -187,6 +187,9 @@ def test_has_oblast_marker(text: str, expected: bool) -> None: ("Серова 27", False), # word-boundary — НЕ город "Серов" ("мкр Заречный, ул. Ленина 5", False), # район внутри города, не ЗАТО Заречный ("Каменск-Уральский, ул. Ленина 1", True), + # явное "Екатеринбург" перевешивает омоним соседнего города в названии + # ЖК/района внутри города (реальный false-positive из прод-кэша, #11): + ("24, опалихинская улица, заречный, Верх-Исетский район, Екатеринбург", False), ], ) def test_names_non_ekb_city(address: str, expected: bool) -> None: From 8fdcd8df625bbe5d47eb945f064fb74dae4cbc97 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Mon, 13 Jul 2026 00:06:19 +0300 Subject: [PATCH 3/3] fix(tradein/geocoder): word-boundary EKB short-circuit + gate suggest() + gazetteer (#11) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deep-review fast-follows on the non-EKB city gate: 1. Word-boundary the "екатеринбург" short-circuit in _names_non_ekb_city (new _EKATERINBURG_RE = \bекатеринбург\b, .search() instead of substring `in`). A bare-substring check matched "Екатеринбургское шоссе" (a real street in satellite towns like Pervouralsk) as if it named EKB, wrongly keeping EKB-only local tiers on for a non-EKB address. Still returns False for the existing homonym false-positive test case (word boundary is satisfied there — "екатеринбург" appears as a standalone token). 2. Gate suggest()'s Tier 1 (same root cause as geocode()): the EKB-only cadastral matchers _cadastral_house_match / _cadastral_forward_sync ran unconditionally, so a non-EKB oblast autocomplete query could surface an EKB building via street+house collision. Gated behind `not _names_non_ekb_city(query)`, mirroring geocode()'s use_local_ekb. External suggest tiers (DaData/Yandex/Nominatim) untouched — still the path for non-EKB autocomplete. 3. Extended SVERDLOVSK_OBLAST_CITIES with unambiguous, deal-heavy oblast cities: алапаевск, сухой лог, кушва, красноуральск, карпинск, нижняя тура, верхний тагил, нижние серги. Deliberately did NOT add "лесной" (reviewer flagged as reviewer-optional, left to judgment): DB check (ekb_geoportal_buildings) confirms a real EKB street named exactly "Лесной" (3 buildings) — an exact whole-word collision indistinguishable from ЗАТО Лесной by word-boundary matching alone (unlike "Серова"/"Серов", which are different word forms). Adding it would misclassify a bare EKB address ("Лесной, 5", no "Екатеринбург" mention) as non-EKB. Tests: word-boundary EKB regression test, suggest() gating test, two new gazetteer entries (Верхняя Пышма multi-word, Сухой Лог). --- tradein-mvp/backend/app/services/geocoder.py | 34 ++++++++++++++---- .../backend/tests/test_geocoder_bbox.py | 6 ++++ .../tests/test_geocoder_cadastral_matcher.py | 36 +++++++++++++++++++ 3 files changed, 70 insertions(+), 6 deletions(-) diff --git a/tradein-mvp/backend/app/services/geocoder.py b/tradein-mvp/backend/app/services/geocoder.py index cdc1178d..1bf0817f 100644 --- a/tradein-mvp/backend/app/services/geocoder.py +++ b/tradein-mvp/backend/app/services/geocoder.py @@ -135,6 +135,19 @@ SVERDLOVSK_OBLAST_CITIES = frozenset( "артёмовский", "артемовский", "камышлов", + "алапаевск", + "сухой лог", + "кушва", + "красноуральск", + "карпинск", + "нижняя тура", + "верхний тагил", + "нижние серги", + # НЕ добавляем "лесной": ekb_geoportal_buildings содержит реальную ЕКБ-улицу + # «Лесной» (3 дома) — точное совпадение целого слова, не отличимое от + # города ЗАТО Лесной word-boundary'ом (в отличие от «Серова»/«Серов», + # где разные словоформы). Бары-адрес "Лесной, 5" без "Екатеринбург" и + # без district-префикса ложно ушёл бы в non-EKB gate. } ) SVERDLOVSK_OBLAST_REGION = "Свердловская область" @@ -185,6 +198,9 @@ _NON_EKB_OBLAST_CITIES = SVERDLOVSK_OBLAST_CITIES - {"екатеринбург"} _NON_EKB_CITY_RE = re.compile( r"\b(?:" + "|".join(re.escape(c) for c in _NON_EKB_OBLAST_CITIES) + r")\b" ) +# Word-boundary, НЕ substring — «екатеринбургское шоссе» (реальная улица в +# городах-спутниках, напр. Первоуральске) не должно матчить как «Екатеринбург». +_EKATERINBURG_RE = re.compile(r"\bекатеринбург\b") def _names_non_ekb_city(address: str) -> bool: @@ -200,10 +216,12 @@ def _names_non_ekb_city(address: str) -> bool: отсекали бы EKB-only тиры для реального ЕКБ-адреса. """ normalized = " ".join(address.lower().split()) - # Явное "Екатеринбург" в адресе → это ЕКБ, даже если в тексте встречается - # омоним соседнего города (ЖК «Заречный», пос. Сысерть в составе МО «город - # Екатеринбург» и т.п. — реальные false-positive из прод-кэша). - if "екатеринбург" in normalized: + # Явное "Екатеринбург" (отдельным словом — см. `_EKATERINBURG_RE`) в адресе + # → это ЕКБ, даже если в тексте встречается омоним соседнего города (ЖК + # «Заречный», пос. Сысерть в составе МО «город Екатеринбург» и т.п. — + # реальные false-positive из прод-кэша). Word-boundary же не даёт слову + # «Екатеринбургское» (шоссе в городах-спутниках) ложно сработать как город. + if _EKATERINBURG_RE.search(normalized): return False for m in _NON_EKB_CITY_RE.finditer(normalized): prefix_words = normalized[: m.start()].split() @@ -1080,8 +1098,12 @@ async def suggest(query: str, db: Session | None = None, limit: int = 8) -> list if not query or len(query.strip()) < 2: return [] - # Tier 1: cadastral FDW (если db доступна) — самый быстрый, без внешних запросов - if db is not None: + # 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): # 1a. Anchored house-match: парсим street+house → точный матч по дом-маркеру. # Решает кейс «Серова 27» где raw-ILIKE по readable_address давал 0 hits. parsed = _parse_street_house(query.strip()) diff --git a/tradein-mvp/backend/tests/test_geocoder_bbox.py b/tradein-mvp/backend/tests/test_geocoder_bbox.py index 56a7e192..b0afa8ed 100644 --- a/tradein-mvp/backend/tests/test_geocoder_bbox.py +++ b/tradein-mvp/backend/tests/test_geocoder_bbox.py @@ -190,6 +190,12 @@ def test_has_oblast_marker(text: str, expected: bool) -> None: # явное "Екатеринбург" перевешивает омоним соседнего города в названии # ЖК/района внутри города (реальный false-positive из прод-кэша, #11): ("24, опалихинская улица, заречный, Верх-Исетский район, Екатеринбург", False), + # word-boundary на "екатеринбург" — "Екатеринбургское шоссе" НЕ город + # Екатеринбург (реальная улица в городах-спутниках, напр. Первоуральске): + ("Первоуральск, Екатеринбургское шоссе, 5", True), + # расширенный gazetteer (deep-review fast-follow): + ("Верхняя Пышма, ул. Ленина 5", True), + ("Сухой Лог, ул. Мира 3", True), ], ) def test_names_non_ekb_city(address: str, expected: bool) -> None: diff --git a/tradein-mvp/backend/tests/test_geocoder_cadastral_matcher.py b/tradein-mvp/backend/tests/test_geocoder_cadastral_matcher.py index 2b3a748f..c5d407fc 100644 --- a/tradein-mvp/backend/tests/test_geocoder_cadastral_matcher.py +++ b/tradein-mvp/backend/tests/test_geocoder_cadastral_matcher.py @@ -373,3 +373,39 @@ async def test_suggest_falls_back_to_legacy_forward_when_house_match_misses() -> mock_house.assert_called_once() mock_forward.assert_called_once() mock_yandex.assert_not_called() + + +async def test_suggest_skips_ekb_local_tier_for_non_ekb_city() -> None: + """Non-EKB oblast query (Нижний Тагил) → EKB-only cadastral Tier 1 + (`_cadastral_house_match` / `_cadastral_forward_sync`) NOT called; falls + through to external tiers, same root cause as `geocode()` (#11).""" + db = MagicMock() + nominatim_hit = [ + GeocodeSuggestion( + label="проспект Ленина, 1", + full_address="проспект Ленина, 1, Нижний Тагил", + lat=57.905, + lon=59.950, + 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("Нижний Тагил, проспект Ленина, 1", db=db, limit=8) + + assert len(results) == 1 + assert results[0].lat == pytest.approx(57.905) + mock_house.assert_not_called() + mock_forward.assert_not_called() + mock_nominatim.assert_called_once()