fix(tradein/geocoder): gate EKB-local tiers for non-EKB oblast addresses (#11)
All checks were successful
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / test (push) Successful in 51s
Deploy Trade-In / build-backend (push) Successful in 1m3s
Deploy Trade-In / deploy (push) Successful in 5m52s
All checks were successful
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / test (push) Successful in 51s
Deploy Trade-In / build-backend (push) Successful in 1m3s
Deploy Trade-In / deploy (push) Successful in 5m52s
Non-EKB Свердловская-область addresses no longer snap into Екатеринбург. Root cause: geocode()/suggest() ran EKB-only local tiers (geoportal/cadastral) before the oblast-aware external providers, with no city gate — a non-EKB street+house colliding with an EKB building resolved to EKB coords. Fix gates the local tiers via _names_non_ekb_city (reuses SVERDLOVSK_OBLAST_CITIES, word-boundary + district-prefix exclusion + \bекатеринбург\b short-circuit). EKB path byte-identical. Deep-review APPROVE, CI green, live-verified (Nominatim returns correct Н.Тагил coords once local tiers skip). Follow-up (main): geocode_cache purge of ~323 poisoned rows.
This commit is contained in:
commit
348be445e3
3 changed files with 193 additions and 16 deletions
|
|
@ -135,6 +135,19 @@ SVERDLOVSK_OBLAST_CITIES = frozenset(
|
||||||
"артёмовский",
|
"артёмовский",
|
||||||
"артемовский",
|
"артемовский",
|
||||||
"камышлов",
|
"камышлов",
|
||||||
|
"алапаевск",
|
||||||
|
"сухой лог",
|
||||||
|
"кушва",
|
||||||
|
"красноуральск",
|
||||||
|
"карпинск",
|
||||||
|
"нижняя тура",
|
||||||
|
"верхний тагил",
|
||||||
|
"нижние серги",
|
||||||
|
# НЕ добавляем "лесной": ekb_geoportal_buildings содержит реальную ЕКБ-улицу
|
||||||
|
# «Лесной» (3 дома) — точное совпадение целого слова, не отличимое от
|
||||||
|
# города ЗАТО Лесной word-boundary'ом (в отличие от «Серова»/«Серов»,
|
||||||
|
# где разные словоформы). Бары-адрес "Лесной, 5" без "Екатеринбург" и
|
||||||
|
# без district-префикса ложно ушёл бы в non-EKB gate.
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
SVERDLOVSK_OBLAST_REGION = "Свердловская область"
|
SVERDLOVSK_OBLAST_REGION = "Свердловская область"
|
||||||
|
|
@ -178,6 +191,46 @@ def _has_oblast_marker(text_lower: str) -> bool:
|
||||||
return False
|
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"
|
||||||
|
)
|
||||||
|
# Word-boundary, НЕ substring — «екатеринбургское шоссе» (реальная улица в
|
||||||
|
# городах-спутниках, напр. Первоуральске) не должно матчить как «Екатеринбург».
|
||||||
|
_EKATERINBURG_RE = re.compile(r"\bекатеринбург\b")
|
||||||
|
|
||||||
|
|
||||||
|
def _names_non_ekb_city(address: str) -> bool:
|
||||||
|
"""True если адрес явно называет город Свердловской области, ОТЛИЧНЫЙ от
|
||||||
|
Екатеринбурга (по границе слова, с исключением district-приставок «мкр/р-н
|
||||||
|
<город>»). Используется чтобы НЕ пускать EKB-only локальные тиры
|
||||||
|
(geoportal/cadastral) на не-ЕКБ адрес: улица+дом, совпадающие с ЕКБ-домом
|
||||||
|
(проспект Ленина 1 есть в обоих городах), иначе адрес снапается в ЕКБ (#11).
|
||||||
|
|
||||||
|
Явное "Екатеринбург" в адресе имеет приоритет над любым омонимом другого
|
||||||
|
города региона — иначе ЖК/посёлки с названием, совпадающим с другим
|
||||||
|
городом области (ЖК «Заречный», пос. «Сысерть» внутри ЕКБ), ложно
|
||||||
|
отсекали бы EKB-only тиры для реального ЕКБ-адреса.
|
||||||
|
"""
|
||||||
|
normalized = " ".join(address.lower().split())
|
||||||
|
# Явное "Екатеринбург" (отдельным словом — см. `_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()
|
||||||
|
if prefix_words and prefix_words[-1] in _DISTRICT_PREFIXES:
|
||||||
|
continue # «мкр Заречный» — район внутри другого города, не ЗАТО Заречный
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
# ── Address normalisation ───────────────────────────────────────────────────
|
# ── Address normalisation ───────────────────────────────────────────────────
|
||||||
def normalize_address(address: str) -> str:
|
def normalize_address(address: str) -> str:
|
||||||
"""Нормализация для cache lookup: lowercase + trim + collapse whitespace.
|
"""Нормализация для cache lookup: lowercase + trim + collapse whitespace.
|
||||||
|
|
@ -1045,8 +1098,12 @@ async def suggest(query: str, db: Session | None = None, limit: int = 8) -> list
|
||||||
if not query or len(query.strip()) < 2:
|
if not query or len(query.strip()) < 2:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
# Tier 1: cadastral FDW (если db доступна) — самый быстрый, без внешних запросов
|
# Tier 1: cadastral FDW (если db доступна) — самый быстрый, без внешних запросов.
|
||||||
if db is not None:
|
# 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 → точный матч по дом-маркеру.
|
# 1a. Anchored house-match: парсим street+house → точный матч по дом-маркеру.
|
||||||
# Решает кейс «Серова 27» где raw-ILIKE по readable_address давал 0 hits.
|
# Решает кейс «Серова 27» где raw-ILIKE по readable_address давал 0 hits.
|
||||||
parsed = _parse_street_house(query.strip())
|
parsed = _parse_street_house(query.strip())
|
||||||
|
|
@ -1113,8 +1170,14 @@ async def geocode(address: str, db: Session) -> GeocodeResult | None:
|
||||||
# 2. Локальные источники по street+house (без внешнего API).
|
# 2. Локальные источники по street+house (без внешнего API).
|
||||||
parsed = _parse_street_house(address.strip())
|
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%).
|
# 2a. Геопортал ЕКБ — ПЕРВЫЙ локальный tier (полнее cad_buildings ~на 70%).
|
||||||
if parsed is not None:
|
if use_local_ekb and parsed is not None:
|
||||||
street, house = parsed
|
street, house = parsed
|
||||||
try:
|
try:
|
||||||
hit = await asyncio.to_thread(_geoportal_house_match, db, street, house)
|
hit = await asyncio.to_thread(_geoportal_house_match, db, street, house)
|
||||||
|
|
@ -1142,7 +1205,7 @@ async def geocode(address: str, db: Session) -> GeocodeResult | None:
|
||||||
# Anchored house-match: парсим street+house → точный матч по дом-маркеру.
|
# Anchored house-match: парсим street+house → точный матч по дом-маркеру.
|
||||||
# raw-ILIKE по полному readable_address давал 0 hits для «Серова 27» / DaData-форм
|
# 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
|
street, house = parsed
|
||||||
hit = await asyncio.to_thread(_cadastral_house_match, db, street, house)
|
hit = await asyncio.to_thread(_cadastral_house_match, db, street, house)
|
||||||
if hit is not None:
|
if hit is not None:
|
||||||
|
|
@ -1163,7 +1226,10 @@ async def geocode(address: str, db: Session) -> GeocodeResult | None:
|
||||||
return result
|
return result
|
||||||
|
|
||||||
# 2d. Fallback: legacy raw-ILIKE forward search (для нераспарсенных форм)
|
# 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:
|
if cad_suggestions:
|
||||||
s = cad_suggestions[0]
|
s = cad_suggestions[0]
|
||||||
result = GeocodeResult(
|
result = GeocodeResult(
|
||||||
|
|
@ -1174,7 +1240,9 @@ async def geocode(address: str, db: Session) -> GeocodeResult | None:
|
||||||
confidence="exact",
|
confidence="exact",
|
||||||
)
|
)
|
||||||
await asyncio.to_thread(_cache_put, db, addr_norm, result)
|
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
|
return result
|
||||||
|
|
||||||
# 3. Yandex (если есть key) с typo-fallback
|
# 3. Yandex (если есть key) с typo-fallback
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ from app.services.geocoder import (
|
||||||
EKB_BBOX_WIDE,
|
EKB_BBOX_WIDE,
|
||||||
OBLAST66_BBOX,
|
OBLAST66_BBOX,
|
||||||
_has_oblast_marker,
|
_has_oblast_marker,
|
||||||
|
_names_non_ekb_city,
|
||||||
_nominatim_query,
|
_nominatim_query,
|
||||||
_yandex_lookup,
|
_yandex_lookup,
|
||||||
is_within_ekb_bbox,
|
is_within_ekb_bbox,
|
||||||
|
|
@ -174,6 +175,33 @@ def test_has_oblast_marker(text: str, expected: bool) -> None:
|
||||||
assert _has_oblast_marker(text) is expected
|
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),
|
||||||
|
# явное "Екатеринбург" перевешивает омоним соседнего города в названии
|
||||||
|
# ЖК/района внутри города (реальный 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:
|
||||||
|
assert _names_non_ekb_city(address) is expected
|
||||||
|
|
||||||
|
|
||||||
# ── Accept-site two-pass tie-break + region cross-check ─────────────────────
|
# ── 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
|
from app.services.geocoder import ( # noqa: E402
|
||||||
|
GeocodeResult,
|
||||||
GeocodeSuggestion,
|
GeocodeSuggestion,
|
||||||
_cadastral_house_match,
|
_cadastral_house_match,
|
||||||
_parse_street_house,
|
_parse_street_house,
|
||||||
|
|
@ -264,6 +265,50 @@ async def test_geocode_skips_house_match_when_parse_fails() -> None:
|
||||||
mock_forward.assert_called_once()
|
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 ─────────────────────────────────────────────────────────
|
# ── suggest() wiring ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -328,3 +373,39 @@ async def test_suggest_falls_back_to_legacy_forward_when_house_match_misses() ->
|
||||||
mock_house.assert_called_once()
|
mock_house.assert_called_once()
|
||||||
mock_forward.assert_called_once()
|
mock_forward.assert_called_once()
|
||||||
mock_yandex.assert_not_called()
|
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()
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue