Merge pull request 'fix(tradein/geocoder): fail-closed выбор ЕКБ-реестров вместо списка городов (#2582)' (#2589) from fix/tradein-geocoder-fail-closed into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 2m18s
Deploy Trade-In / build-backend (push) Successful in 1m4s
Deploy Trade-In / deploy (push) Successful in 1m13s
All checks were successful
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 2m18s
Deploy Trade-In / build-backend (push) Successful in 1m4s
Deploy Trade-In / deploy (push) Successful in 1m13s
This commit is contained in:
commit
fdf222cc5c
3 changed files with 504 additions and 22 deletions
|
|
@ -284,6 +284,117 @@ def _names_non_ekb_city(address: str) -> bool:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# ── Fail-closed EKB-local-tiers gate (#2582) ─────────────────────────────────
|
||||||
|
# `_names_non_ekb_city` выше ловит только 37 ПЕРЕЧИСЛЕННЫХ городов области —
|
||||||
|
# любой другой топоним (другой регион РФ целиком, ИЛИ областной город вне
|
||||||
|
# списка: Реж/Арамиль/Рефтинский/Лёвиха) молча проходил как «не назван», и
|
||||||
|
# EKB-only локальные тиры (geoportal/cad_buildings) снапали улицу+дом в ЕКБ по
|
||||||
|
# коллизии (см. живые случаи в geocode_cache: «республика крым, ялта, ...» →
|
||||||
|
# координаты ЕКБ, «челябинская обл., трёхгорный, ...» → координаты ЕКБ).
|
||||||
|
#
|
||||||
|
# Вместо перечисления (сама причина бага — список никогда не покроет все
|
||||||
|
# города РФ) используем общий структурный признак «в адресе назван
|
||||||
|
# город/регион»: маркер-слово, которое НИКОГДА не относится к улице/дому
|
||||||
|
# («обл.», «край», «республика», «г.», «пос.»).
|
||||||
|
#
|
||||||
|
# Два разных класса маркеров — разная форма 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(?:с|д|дер|пгт|ст)\.\s*(?=[а-яё])"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _names_unrecognized_locality(address: str) -> bool:
|
||||||
|
"""True если адрес называет топоним, но это не Екатеринбург и не город из
|
||||||
|
известного списка `SVERDLOVSK_OBLAST_CITIES` (#2582).
|
||||||
|
|
||||||
|
Отличает «город не назван» (bare «Ленина 1» — так приходит основной
|
||||||
|
трафик формы оценки; geoportal резолвит его как ЕКБ и это оправданный
|
||||||
|
дефолт) от «назван НЕЗНАКОМЫЙ город» (Ялта/Трёхгорный/Лёвиха, «с. Кашино»,
|
||||||
|
«д. Родина» — структурный маркер топонима есть, но это не ЕКБ и не
|
||||||
|
известный город региона 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):
|
||||||
|
return False # явный ЕКБ — приоритет, см. _names_non_ekb_city
|
||||||
|
return bool(_LOCALITY_MARKER_RE.search(normalized))
|
||||||
|
|
||||||
|
|
||||||
|
def _ekb_local_tiers_allowed(address: str, city_hint: str | None = None) -> bool:
|
||||||
|
"""Fail-closed гейт локальных ЕКБ-тиров geocoder (`geocode()`/`suggest()`, #2582).
|
||||||
|
|
||||||
|
ЕКБ-only локальные реестры (geoportal/cad_buildings) используются ТОЛЬКО
|
||||||
|
когда Екатеринбург подтверждён явно (текстом адреса или `city_hint`), или
|
||||||
|
нигде не назван никакой город/регион. Гейт закрывается, если сработал
|
||||||
|
ЛЮБОЙ из трёх независимых сигналов (OR, не приоритетная цепочка):
|
||||||
|
|
||||||
|
- известный город региона 66 в ТЕКСТЕ адреса (`_names_non_ekb_city`, без
|
||||||
|
изменений, #11);
|
||||||
|
- `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: «обл./край/республика/г./пос.»
|
||||||
|
и однобуквенные «с./д./дер./пгт./ст.» с lookahead на кириллицу после
|
||||||
|
точки) — покрывает города вне списка 37 С маркером (Лёвиха, «с.
|
||||||
|
Новоалексеевское») и ДРУГИЕ РЕГИОНЫ РФ целиком (Ялта, Трёхгорный), не
|
||||||
|
требуя их перечислять. Голый город без маркера и вне списка (Реж без
|
||||||
|
«обл.») — известная дыра, см. `_names_unrecognized_locality`.
|
||||||
|
|
||||||
|
До #2582 решение по третьему пункту принималось от противного: «нет в
|
||||||
|
списке 37 городов → считаем Екатеринбургом» — молча ломало любой адрес
|
||||||
|
вне списка (см. docstring `_names_unrecognized_locality`).
|
||||||
|
|
||||||
|
Note: т.к. это OR трёх независимых сигналов (не приоритетная цепочка),
|
||||||
|
`city_hint`, противоречащий явному "Екатеринбург" в тексте адреса
|
||||||
|
(`_names_non_ekb_city(address)` для такого текста вернёт False и не
|
||||||
|
закроет гейт сам по себе — Екатеринбург в тексте не мешает), тем не менее
|
||||||
|
ЗАКРОЕТ гейт через `hint_blocks` — тот же trade-off, что уже был одобрен
|
||||||
|
в #2580 (`use_local_ekb`), сюда не переписываем. Нормальный кейс — caller
|
||||||
|
не передаёт противоречащие address и 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_blocks or _names_unrecognized_locality(address)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ── 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.
|
||||||
|
|
@ -1289,14 +1400,14 @@ async def suggest(
|
||||||
return []
|
return []
|
||||||
|
|
||||||
# Tier 1: cadastral FDW (если db доступна) — самый быстрый, без внешних запросов.
|
# Tier 1: cadastral FDW (если db доступна) — самый быстрый, без внешних запросов.
|
||||||
# EKB-only (см. `geocode()` #11) — пропускаем, если query явно называет
|
# EKB-only fail-closed гейт (#2582, было #11) — пропускаем, если query явно
|
||||||
# другой город области, иначе не-ЕКБ автокомплит может всплыть ЕКБ-домом
|
# называет другой топоним: известный город области (`_names_non_ekb_city`),
|
||||||
# с совпадающими улица+дом. Внешние тиры (2/3/4 ниже) не гейтим — они уже
|
# незнакомый — структурный маркер (`_names_unrecognized_locality`), ИЛИ
|
||||||
# oblast-aware.
|
# `city_hint` называет город региона 66, отличный от ЕКБ (#2580 C1 —
|
||||||
# #2580 (C1): city_hint тоже гейтит — иначе он мёртвый параметр для этого
|
# иначе хинт мёртвый параметр для этого тира, см. `_ekb_local_tiers_allowed`
|
||||||
# тира (см. `geocode()` use_local_ekb выше — тот же принцип).
|
# и `geocode()` ниже — тот же гейт). Внешние тиры (2/3/4 ниже) не гейтим —
|
||||||
hint_names_non_ekb = bool(city_hint) and _names_non_ekb_city(city_hint)
|
# они уже oblast-aware.
|
||||||
if db is not None and not (_names_non_ekb_city(query) or hint_names_non_ekb):
|
if db is not None and _ekb_local_tiers_allowed(query, city_hint):
|
||||||
# 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())
|
||||||
|
|
@ -1378,20 +1489,19 @@ async def geocode(address: str, db: Session, city_hint: str | None = None) -> Ge
|
||||||
# 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) не должны отвечать на
|
# #2582 (было #11): EKB-only локальные тиры (geoportal/cadastral) не должны
|
||||||
# адрес другого города области — иначе улица+дом, коллизящие с ЕКБ-домом
|
# отвечать на адрес другого города/региона — иначе улица+дом, коллизящие с
|
||||||
# (напр. "проспект Ленина 1" есть и в Нижнем Тагиле, и в ЕКБ), снапаются в
|
# ЕКБ-домом (напр. "проспект Ленина 1" есть и в Нижнем Тагиле, и в ЕКБ),
|
||||||
# ЕКБ. Пропускаем сразу к oblast-aware внешним провайдерам ниже (3/4).
|
# снапаются в ЕКБ. Fail-closed: разрешаем локальные тиры ТОЛЬКО если
|
||||||
# #2580 (C1): city_hint ДОЛЖЕН участвовать в этом гейте — иначе вызывающий
|
# Екатеринбург подтверждён явно (текстом или `city_hint`, #2580 C1 —
|
||||||
# код, явно передавший city_hint="Нижний Тагил" для "Ленина 1" (в самом
|
# иначе caller, явно передавший city_hint="Нижний Тагил" для "Ленина 1"
|
||||||
# тексте адреса города нет), всё равно попадает на geoportal/cadastral
|
# без города в самом тексте, всё равно получал бы "точный" ЕКБ-хит с
|
||||||
# (ЕКБ-only базы), получает "точный" ЕКБ-хит и city_ambiguous=False (хинт
|
# city_ambiguous=False — ложно-уверенный неверный результат), или
|
||||||
# ведь был!) — т.е. систему, которая раньше просто не знала город, теперь
|
# город/регион вообще не назван нигде — см. `_ekb_local_tiers_allowed`.
|
||||||
# ложно-уверенно утверждает неверный. `_names_non_ekb_city` НЕ переписываем
|
# Раньше решение по тексту адреса принималось от противного (список из 37
|
||||||
# (гейт/gazetteer — отдельный дефект #2582), только добавляем city_hint
|
# городов — «нет в списке → считаем ЕКБ»), из-за чего любой другой регион
|
||||||
# на вход той же самой проверки.
|
# РФ (Ялта, Трёхгорный) молча резолвился в координаты ЕКБ (#2582).
|
||||||
hint_names_non_ekb = bool(city_hint) and _names_non_ekb_city(city_hint)
|
use_local_ekb = _ekb_local_tiers_allowed(address, city_hint)
|
||||||
use_local_ekb = not (_names_non_ekb_city(address) or hint_names_non_ekb)
|
|
||||||
|
|
||||||
# 2a. Геопортал ЕКБ — ПЕРВЫЙ локальный tier (полнее cad_buildings ~на 70%).
|
# 2a. Геопортал ЕКБ — ПЕРВЫЙ локальный tier (полнее cad_buildings ~на 70%).
|
||||||
if use_local_ekb and parsed is not None:
|
if use_local_ekb and parsed is not None:
|
||||||
|
|
|
||||||
|
|
@ -18,11 +18,14 @@ import httpx
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from app.services.geocoder import (
|
from app.services.geocoder import (
|
||||||
|
_LOCALITY_MARKER_RE,
|
||||||
EKB_BBOX_TIGHT,
|
EKB_BBOX_TIGHT,
|
||||||
EKB_BBOX_WIDE,
|
EKB_BBOX_WIDE,
|
||||||
OBLAST66_BBOX,
|
OBLAST66_BBOX,
|
||||||
|
_ekb_local_tiers_allowed,
|
||||||
_has_oblast_marker,
|
_has_oblast_marker,
|
||||||
_names_non_ekb_city,
|
_names_non_ekb_city,
|
||||||
|
_names_unrecognized_locality,
|
||||||
_nominatim_query,
|
_nominatim_query,
|
||||||
_yandex_lookup,
|
_yandex_lookup,
|
||||||
is_within_ekb_bbox,
|
is_within_ekb_bbox,
|
||||||
|
|
@ -202,6 +205,162 @@ def test_names_non_ekb_city(address: str, expected: bool) -> None:
|
||||||
assert _names_non_ekb_city(address) is expected
|
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),
|
||||||
|
# ── Изолирующие кейсы (#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",
|
||||||
|
[
|
||||||
|
# Живые прод-случаи (#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),
|
||||||
|
# изолирующий кейс (#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, #2589 M2) ─────
|
||||||
|
# Обе логики независимо закрывают гейт (OR трёх сигналов) — проверяем, что
|
||||||
|
# ни одна из веток не стала недостижимой после слияния #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="Екатеринбург" (без конфликта в тексте) → открыто.
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"address,city_hint,expected,label",
|
||||||
|
[
|
||||||
|
# A: hint называет известный не-ЕКБ город региона 66 → закрыто, даже
|
||||||
|
# если сам текст адреса города вообще не называет (#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: маркер в тексте"),
|
||||||
|
# C: ни маркера в тексте, ни (не-ЕКБ) hint'а → открыто, основной трафик.
|
||||||
|
("Малышева 30", None, True, "C: ничего не названо"),
|
||||||
|
("Серова 27", "", True, "C: пустой hint == отсутствию hint'а"),
|
||||||
|
# D: явный city_hint="Екатеринбург" без конфликта в тексте → открыто —
|
||||||
|
# ключевая проверка, что #2580 C1 / #2589 M2 не сломали дефолтный
|
||||||
|
# ЕКБ-путь через hint.
|
||||||
|
("Ленина, 1", "Екатеринбург", True, "D: hint=Екатеринбург"),
|
||||||
|
("Серова 27", "екатеринбург", True, "D: hint=екатеринбург (регистр)"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_ekb_local_tiers_allowed_with_city_hint(
|
||||||
|
address: str, city_hint: str | None, expected: bool, label: str
|
||||||
|
) -> None:
|
||||||
|
assert _ekb_local_tiers_allowed(address, city_hint) is expected, label
|
||||||
|
|
||||||
|
|
||||||
# ── Accept-site two-pass tie-break + region cross-check ─────────────────────
|
# ── Accept-site two-pass tie-break + region cross-check ─────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -309,6 +309,176 @@ async def test_geocode_skips_ekb_local_tiers_for_non_ekb_city() -> None:
|
||||||
mock_nominatim.assert_called_once()
|
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)"),
|
||||||
|
(
|
||||||
|
"с. Новоалексеевское, ул. 8 Марта,37",
|
||||||
|
"Новоалексеевское (живой инцидент #2589 H1 — «с.»-маркер без доп. «обл.»)",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
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", "ЕКБ с явным городом"),
|
||||||
|
("ул. Серова, д. 27", "house-маркер «д.» не закрывает гейт (#2589 H1 regression)"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
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()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_geocode_explicit_ekaterinburg_city_hint_still_uses_local_tiers() -> None:
|
||||||
|
"""Слияние #2580/#2582: явный `city_hint="Екатеринбург"` (без города в самом
|
||||||
|
тексте адреса, без конфликта) по-прежнему открывает ЕКБ-only локальные
|
||||||
|
тиры — ветка D из объединённого гейта `_ekb_local_tiers_allowed` не должна
|
||||||
|
была стать недостижимой после merge."""
|
||||||
|
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("Серова 27", db, city_hint="Екатеринбург")
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
assert result.lat == pytest.approx(56.81188)
|
||||||
|
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 ─────────────────────────────────────────────────────────
|
# ── suggest() wiring ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -409,3 +579,46 @@ async def test_suggest_skips_ekb_local_tier_for_non_ekb_city() -> None:
|
||||||
mock_house.assert_not_called()
|
mock_house.assert_not_called()
|
||||||
mock_forward.assert_not_called()
|
mock_forward.assert_not_called()
|
||||||
mock_nominatim.assert_called_once()
|
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()
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue