fix(tradein/geocoder): word-boundary EKB short-circuit + gate suggest() + gazetteer (#11)
All checks were successful
CI / changes (pull_request) Successful in 7s
CI Trade-In / changes (pull_request) Successful in 8s
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 56s
All checks were successful
CI / changes (pull_request) Successful in 7s
CI Trade-In / changes (pull_request) Successful in 8s
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 56s
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, Сухой Лог).
This commit is contained in:
parent
2ef3e42da1
commit
8fdcd8df62
3 changed files with 70 additions and 6 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 = "Свердловская область"
|
||||||
|
|
@ -185,6 +198,9 @@ _NON_EKB_OBLAST_CITIES = SVERDLOVSK_OBLAST_CITIES - {"екатеринбург"}
|
||||||
_NON_EKB_CITY_RE = re.compile(
|
_NON_EKB_CITY_RE = re.compile(
|
||||||
r"\b(?:" + "|".join(re.escape(c) for c in _NON_EKB_OBLAST_CITIES) + r")\b"
|
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:
|
def _names_non_ekb_city(address: str) -> bool:
|
||||||
|
|
@ -200,10 +216,12 @@ def _names_non_ekb_city(address: str) -> bool:
|
||||||
отсекали бы EKB-only тиры для реального ЕКБ-адреса.
|
отсекали бы EKB-only тиры для реального ЕКБ-адреса.
|
||||||
"""
|
"""
|
||||||
normalized = " ".join(address.lower().split())
|
normalized = " ".join(address.lower().split())
|
||||||
# Явное "Екатеринбург" в адресе → это ЕКБ, даже если в тексте встречается
|
# Явное "Екатеринбург" (отдельным словом — см. `_EKATERINBURG_RE`) в адресе
|
||||||
# омоним соседнего города (ЖК «Заречный», пос. Сысерть в составе МО «город
|
# → это ЕКБ, даже если в тексте встречается омоним соседнего города (ЖК
|
||||||
# Екатеринбург» и т.п. — реальные false-positive из прод-кэша).
|
# «Заречный», пос. Сысерть в составе МО «город Екатеринбург» и т.п. —
|
||||||
if "екатеринбург" in normalized:
|
# реальные false-positive из прод-кэша). Word-boundary же не даёт слову
|
||||||
|
# «Екатеринбургское» (шоссе в городах-спутниках) ложно сработать как город.
|
||||||
|
if _EKATERINBURG_RE.search(normalized):
|
||||||
return False
|
return False
|
||||||
for m in _NON_EKB_CITY_RE.finditer(normalized):
|
for m in _NON_EKB_CITY_RE.finditer(normalized):
|
||||||
prefix_words = normalized[: m.start()].split()
|
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:
|
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())
|
||||||
|
|
|
||||||
|
|
@ -190,6 +190,12 @@ def test_has_oblast_marker(text: str, expected: bool) -> None:
|
||||||
# явное "Екатеринбург" перевешивает омоним соседнего города в названии
|
# явное "Екатеринбург" перевешивает омоним соседнего города в названии
|
||||||
# ЖК/района внутри города (реальный false-positive из прод-кэша, #11):
|
# ЖК/района внутри города (реальный false-positive из прод-кэша, #11):
|
||||||
("24, опалихинская улица, заречный, Верх-Исетский район, Екатеринбург", False),
|
("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:
|
def test_names_non_ekb_city(address: str, expected: bool) -> None:
|
||||||
|
|
|
||||||
|
|
@ -373,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