fix(tradein/geocoder): gate EKB-local tiers for non-EKB oblast addresses (#11)
All checks were successful
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 53s
CI / changes (pull_request) Successful in 8s
CI Trade-In / changes (pull_request) Successful in 9s
All checks were successful
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 53s
CI / changes (pull_request) Successful in 8s
CI Trade-In / changes (pull_request) Successful in 9s
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.
This commit is contained in:
parent
73f932d60a
commit
720cee2c8e
3 changed files with 114 additions and 14 deletions
|
|
@ -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,7 +1194,10 @@ 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 use_local_ekb:
|
||||
cad_suggestions = await asyncio.to_thread(
|
||||
_cadastral_forward_sync, db, address.strip(), limit=1
|
||||
)
|
||||
if cad_suggestions:
|
||||
s = cad_suggestions[0]
|
||||
result = GeocodeResult(
|
||||
|
|
@ -1174,7 +1208,9 @@ async def geocode(address: str, db: Session) -> GeocodeResult | None:
|
|||
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)
|
||||
logger.info(
|
||||
"geocode cadastral fdw: %s → (%.5f, %.5f)", addr_norm, result.lat, result.lon
|
||||
)
|
||||
return result
|
||||
|
||||
# 3. Yandex (если есть key) с typo-fallback
|
||||
|
|
|
|||
|
|
@ -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 ─────────────────────
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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 ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue