fix(tradein/geocoder): литера дома — часть идентичности, а не tie-break
All checks were successful
CI / changes (pull_request) Successful in 8s
CI Trade-In / changes (pull_request) Successful in 9s
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 2m44s

`_cadastral_house_match` сравнивал дом только по ЦИФРАМ: литера была
опциональна в WHERE (`[а-яё]?`) и участвовала лишь как tie-break в
ORDER BY. Баг двусторонний и молчаливый:

  «Новгородцевой 13б» → «дом 13»   (дома 13б в реестре нет вообще)
  «Малышева 30»       → «д. 30-б»  (обратное направление, живой прод-кейс)

Оба результата возвращались с `confidence="exact"` и оседали в
`geocode_cache` на 90 дней — пользователь получал оценку ЧУЖОГО здания,
помеченную как точная.

Почему так: номер дома теперь сравнивается РАВЕНСТВОМ нормализованных
форм. Обе стороны приводятся к одному канону (`13б` / `13 б` / `13-б` /
`13Б` → `13б`): запрос — существующим `_norm_house`, реестр — тем же
выражением на стороне Postgres. Дешёвый regex-anchor по цифрам оставлен
prefilter'ом (пушится в FDW, режет «(1-83)»-диапазоны), но корректность
теперь на равенстве, а не на нём.

Побочно закрыты той же логикой:
  • «58» больше не матчит «58/3», «13» не матчит «130» — угловой номер
    это часть номера дома, а не мусор;
  • маркер дома ищется только с начала слова (`\m`), иначе «проезд 8
    Марта, д 5» давал дом «8» — старый `д\.?` ловил «д» внутри «проезд»
    (20 таких строк реестра были достижимы ТОЛЬКО через этот баг);
  • номер дома больше не конкатенируется в regex — regex-injection
    поверхность сузилась до цифр prefilter'а.

Промаха не превращаем в тихую подстановку соседа: нет дома с нужной
литерой → None, дальше отрабатывают следующие тиры (forward → DaData →
Nominatim). Понижать confidence здесь нечем — `GeocodeSuggestion` его не
несёт, все локальные тиры хардкодят "exact"; честный фолбэк дешевле, чем
протаскивать новый канал уверенности через три тира.

DaData-тир, который должен подхватывать такие адреса, был мёртв: в
`locations` уходило `region="Свердловская область"`, тогда как DaData
хранит имя региона БЕЗ типа (`region="Свердловская"`, `region_type="обл"`).
Hard-filter не совпадал ни с чем и молча схлопывал выдачу в 0 подсказок
(замер на проде: 0 хитов против 5 с «Свердловская», первый — искомый
«д 13б» с fias_id). Добавлен warning на пустую выдачу под region-
констрейнтом, чтобы следующая такая регрессия не была невидимой.

Миграция 203 инвалидирует уже отравленные записи `geocode_cache` —
точечно (запрос с литерой, либо запрос без литеры с закэшированным
домом С литерой), а не весь кэш: полная очистка сожгла бы квоту DaData
на ре-резолв заведомо корректных адресов.

Проверено: SQL-выражение прогнано против живого реестра (47k строк) —
«13б»→∅, «13»→дом 13, «30»→сооружение 30 (не «30-б»), «23б»→«д. 23-б»;
предикат миграции — против таблицы кейсов (4 delete / 8 keep).
Полный pytest: 1 failed, 3123 passed — падение
`test_search_api.py::test_search_cache_hit` (401) воспроизводится и на
чистом main (baseline 1 failed, 3081 passed), к этому изменению
отношения не имеет.

НЕ проверено вживую: ответ DaData с новым region-констрейнтом (нет
доступа к прод-токену из этой сессии) — опираюсь на замер из отчёта об
issue и на модель данных DaData.
This commit is contained in:
bot-backend 2026-08-02 13:30:16 +03:00
parent f488cbcf03
commit d63702cfec
4 changed files with 404 additions and 25 deletions

View file

@ -343,6 +343,10 @@ async def suggest_addresses(
жёсткий фильтр (не boost) на уровне указанного admin-поля доп.
параметров не требуется. По умолчанию не задан поведение (и body
запроса) для существующих вызовов не меняется.
ВАЖНО: значение сравнивается с полем DaData `region`, где имя лежит
БЕЗ типа («Свердловская», а тип отдельно в `region_type`="обл").
Передашь «Свердловская область» совпадений не будет, и запрос
вернёт ПУСТО без всякой ошибки (hard-filter, не boost).
Returns:
list[DadataSuggestion] пустой список если:

View file

@ -156,7 +156,15 @@ SVERDLOVSK_OBLAST_CITIES = frozenset(
# без district-префикса ложно ушёл бы в non-EKB gate.
}
)
SVERDLOVSK_OBLAST_REGION = "Свердловская область"
# Значение для DaData-констрейнта `locations: [{"region": ...}]`.
# ВАЖНО: DaData хранит имя региона БЕЗ типа — `region="Свердловская"`,
# `region_type="обл"` (тип лежит в отдельных полях `region_type` /
# `region_with_type`). `locations` сравнивает именно с `region`, поэтому
# «Свердловская область» не совпадает НИ С ЧЕМ и hard-фильтр молча схлопывал
# выдачу в 0 подсказок (замер на проде: «Свердловская область» → 0 хитов,
# «Свердловская» → 5 хитов, первый — искомый «д 13б» с fias_id).
# Тип региона сюда дописывать нельзя — см. `test_dadata_region_constant_*`.
SVERDLOVSK_OBLAST_REGION = "Свердловская"
# Word/phrase-boundary regex — НЕ substring — чтобы «Серова 27» не матчил город
# «Серов», «Ирбитская 5» — «Ирбит», «Асбестовский пер.» — «Асбест», «Невьянский
@ -637,13 +645,24 @@ async def _dadata_suggest(query: str, limit: int = 8) -> list[GeocodeSuggestion]
типа город/район, для autocomplete с привязкой к карте они бесполезны).
Label собирается из DaData `value` (короткая форма «ул Малышева, д 30»).
Constraint вся область (region='Свердловская область', hard-filter внутри
`suggest_addresses`), а не один город ЕКБ иначе Нижний Тагил/Серов/etc
никогда не появились бы в подсказках.
Constraint вся область (region=`SVERDLOVSK_OBLAST_REGION`, hard-filter
внутри `suggest_addresses`), а не один город ЕКБ иначе Нижний Тагил/
Серов/etc никогда не появились бы в подсказках.
"""
raw = await dadata.suggest_addresses(
query, limit=limit, city=None, region=SVERDLOVSK_OBLAST_REGION
)
if not raw:
# Region-констрейнт — hard-filter: неверное значение схлопывает выдачу в
# 0 БЕЗ ошибки (так и жил баг «Свердловская область» → 0 подсказок).
# Отдельный warning, чтобы следующая такая регрессия была видна в логах,
# а не выглядела как «DaData ничего не знает про этот адрес».
logger.warning(
"dadata suggest: 0 кандидатов для %r при region=%r"
"проверь, что констрейнт совпадает с полем DaData `region` (без типа)",
query[:60],
SVERDLOVSK_OBLAST_REGION,
)
out: list[GeocodeSuggestion] = []
for s in raw:
if s.lat is None or s.lon is None:
@ -946,23 +965,73 @@ def _parse_street_house(address: str) -> tuple[str, str] | None:
return (street, house)
# Извлечение номера дома из `readable_address` реестра. Реальные формы в
# gendesign_cad_buildings (47k строк, замер 2026-08-02):
# «д. 13» / «дом 13» / «сооружение 30» — 21k
# «д. 13б» — 2.6k
# «д. 13-б» — 2.1k
# «д. 13 б» — 125
# «д. 58/3», «д. 64-2» — 0.9k (угловые/корпусные номера)
# «д. 11 (кв. 1-150)», «д. 102 корпус 1» — хвост, литерой НЕ является
# Разбор:
# \m… — маркер только с НАЧАЛА слова, иначе «проезд 8
# Марта, д 5» дало бы дом «8» (старый `д\.?` без
# границы слова ловил «д» внутри «проезд»)
# [0-9]+ — номер
# (\s*[-/]\s*[0-9]+)? — «58/3» / «64-2»: часть номера, а не мусор —
# иначе «58» ложно совпало бы с «58/3»
# (\s*-?\s*[а-яё](?![а-яё]))? — литера; lookahead отсекает начало слова
# («102 корпус 1» → «102», не «102к»)
_SQL_HOUSE_TOKEN_RE = (
r"\m(?:дом|д\.?|строение|стр\.?|сооружение|соор\.?)\s*"
r"([0-9]+(?:\s*[-/]\s*[0-9]+)?(?:\s*-?\s*[а-яё](?![а-яё]))?)"
)
# Нормализация извлечённого токена к канону `_norm_house`: убираем пробелы,
# затем дефис ТОЛЬКО перед литерой («23-б» → «23б», но «64-2» остаётся «64-2»,
# иначе он схлопнулся бы в реальный дом «642»).
_SQL_HOUSE_TOKEN_NORM = (
r"regexp_replace("
r" regexp_replace("
r" lower(COALESCE((regexp_match(readable_address, :house_token_re, 'i'))[1], '')),"
r" '\s', '', 'g'),"
r" '-([а-яё])', '\1', 'g')"
)
def _cadastral_house_match(db: Session, street: str, house: str) -> GeocodeSuggestion | None:
"""Anchored cadastral match: ILIKE по улице + regex-anchor на дом-маркер.
"""Anchored cadastral match: ILIKE по улице + СТРОГОЕ равенство номера дома.
SQL validated на проде (11/16 hits, 0 false positives). Anchor на
«д./дом/строение» убивает ложный матч номера внутри «(1-83)»-диапазона.
Литера часть идентичности дома, а не украшение: «Новгородцевой 13б» и
«Новгородцевой 13» РАЗНЫЕ здания. Поэтому номер сравнивается равенством
нормализованных форм (обе стороны канон «13б»), а не «совпали цифры,
литера опциональна».
`street` идёт ТОЛЬКО в bound-param ILIKE (безопасно). Для regex берём
только ЦИФРЫ дома (regex-safe) конкатенируем bound-param внутри SQL.
Литеру (если есть) используем лишь для tie-break сортировки.
Раньше в regex шли только ЦИФРЫ дома, литера была опциональна в WHERE и
участвовала лишь как tie-break в ORDER BY из-за чего запрос с литерой
молча получал соседний дом БЕЗ неё (и наоборот: «Малышева 30» «д. 30-б»),
причём с `confidence="exact"` и записью в `geocode_cache` на 90 дней.
Regex-anchor на «д./дом/строение» (prefilter) сохранён: он дёшев, пушится
в FDW и убивает ложный матч номера внутри «(1-83)»-диапазона. Точность
даёт равенство токенов ниже.
`street` идёт ТОЛЬКО в bound-param ILIKE, номер дома в regex больше НЕ
конкатенируется (сравнивается как текст) regex-injection поверхность
сузилась до цифр prefilter'а.
Нет дома с нужной литерой возвращаем None, а НЕ «похожий» дом: пусть
отработают следующие тиры (`_cadastral_forward_sync` DaData Nominatim).
Тихо подставленный соседний дом здесь необратимо помечался бы `exact`.
"""
house_digits_m = re.match(r"\d+", house)
house_norm = _norm_house(house)
house_digits_m = re.match(r"\d+", house_norm)
if not house_digits_m:
return None
house_digits = house_digits_m.group(0)
try:
row = db.execute(
text(r"""
text(
r"""
SELECT readable_address, lat, lon
FROM gendesign_cad_buildings
WHERE readable_address ILIKE CAST('%' || :street || '%' AS text)
@ -976,14 +1045,19 @@ def _cadastral_house_match(db: Session, street: str, house: str) -> GeocodeSugge
'(п\.\s|пос[. ]|посёлок|поселок|северка|шабровский'
|| '| км|снт|гараж|коллективный сад)'
)
ORDER BY
(CASE WHEN CAST(:house_full AS text) ~ '[а-яё]'
AND readable_address ~* (CAST(:house_full AS text) || '(\D|$)')
THEN 0 ELSE 1 END),
length(readable_address) ASC
AND """
+ _SQL_HOUSE_TOKEN_NORM
+ r""" = CAST(:house_norm AS text)
ORDER BY length(readable_address) ASC
LIMIT 1
"""),
{"street": street, "house_digits": house_digits, "house_full": house},
"""
),
{
"street": street,
"house_digits": house_digits,
"house_norm": house_norm,
"house_token_re": _SQL_HOUSE_TOKEN_RE,
},
).first()
except Exception:
logger.warning(

View file

@ -0,0 +1,52 @@
-- Инвалидация записей geocode_cache, отравленных багом матчинга литеры дома.
--
-- Контекст: `_cadastral_house_match` (app/services/geocoder.py) сравнивал дом
-- только по ЦИФРАМ — литера была опциональна в WHERE и участвовала лишь как
-- tie-break в ORDER BY. Итог, двусторонний:
-- • «Новгородцевой 13б» → «дом 13» (запрос с литерой → дом без неё)
-- • «Малышева 30» → «д. 30-б» (запрос без литеры → дом с литерой)
-- Оба результата писались с provider-тиром локального реестра и
-- `confidence='exact'`, TTL 90 дней → пользователь получал оценку ЧУЖОГО
-- здания, помеченную как точная, и она залипала в кэше.
--
-- Здесь удаляем только ПОДОЗРИТЕЛЬНЫЕ строки, а не весь кэш: полная очистка
-- сожгла бы квоту внешних геокодеров (DaData 10k/день) на ре-резолв заведомо
-- корректных адресов. Удалённое будет пересчитано лениво, при следующем
-- запросе, уже исправленным матчером.
--
-- Идемпотентность: чистый DELETE по предикату. Повторный прогон удалит 0 строк
-- (первый уже вычистил всё подходящее), новых строк с такой же патологией
-- исправленный код не создаёт. Безопасно для strict exit-1 авто-применения.
BEGIN;
DELETE FROM geocode_cache
WHERE
-- (a) В самом запросе была литера дома: под старым матчером такой адрес мог
-- уехать в дом без литеры / с чужой литерой. Смотрим на ХВОСТ адреса
-- (дом пишется последним) — иначе порядковые части улиц («1-я
-- Пятилетки», «4-й Кианитовый») ложно читались бы как литера.
-- `|city=` — суффикс ключа кэша (см. geocoder._cache_key), отрезаем.
split_part(address_normalized, '|city=', 1) ~* '[0-9]+\s*-?\s*[а-яё]\s*$'
-- (b) Обратное направление: в запросе литеры НЕ было, а закэширован адрес
-- реестра, у которого номер дома С литерой («Малышева 30» → «д. 30-б»).
-- Извлечение номера — то же выражение, что и в исправленном матчере
-- (geocoder._SQL_HOUSE_TOKEN_RE): маркер только с начала слова, литера
-- — одиночная кириллическая буква, «58/3»/«64-2» литерой не считаются.
OR (
split_part(address_normalized, '|city=', 1) !~* '[0-9]+\s*-?\s*[а-яё]\s*$'
AND full_address IS NOT NULL
AND regexp_replace(
regexp_replace(
lower(COALESCE((regexp_match(
full_address,
'\m(?:дом|д\.?|строение|стр\.?|сооружение|соор\.?)\s*'
|| '([0-9]+(?:\s*[-/]\s*[0-9]+)?(?:\s*-?\s*[а-яё](?![а-яё]))?)',
'i'))[1], '')),
'\s', '', 'g'),
'-([а-яё])', '\1', 'g'
) ~ '[а-яё]$'
);
COMMIT;

View file

@ -12,6 +12,7 @@ Covers:
from __future__ import annotations
import os
import re
import sys
from unittest.mock import AsyncMock, MagicMock, patch
@ -26,9 +27,13 @@ sys.modules.setdefault("weasyprint", _wp_mock)
from app.services.geocoder import ( # noqa: E402
_SQL_HOUSE_TOKEN_RE,
SVERDLOVSK_OBLAST_REGION,
GeocodeResult,
GeocodeSuggestion,
_cadastral_house_match,
_dadata_suggest,
_norm_house,
_parse_street_house,
geocode,
suggest,
@ -143,8 +148,8 @@ def test_house_match_returns_none_for_non_numeric_house() -> None:
db.execute.assert_not_called()
def test_house_match_passes_only_digits_as_regex_param() -> None:
"""house='26а' → :house_digits bound param is '26' (letter stripped for regex)."""
def _house_match_params(house: str, street: str = "космонавтов") -> dict:
"""Вызывает матчер с mock-сессией и возвращает bound-params запроса."""
row = MagicMock()
row.readable_address = "г. Екатеринбург, пр-кт Космонавтов, д. 26а"
row.lat = 56.9
@ -154,13 +159,58 @@ def test_house_match_passes_only_digits_as_regex_param() -> None:
result.first.return_value = row
db.execute.return_value = result
_cadastral_house_match(db, "космонавтов", "26а")
_cadastral_house_match(db, street, house)
# second positional arg to execute() is the bound-params dict
params = db.execute.call_args.args[1]
assert params["house_digits"] == "26"
assert params["house_full"] == "26а"
return db.execute.call_args.args[1]
def test_house_match_passes_full_normalized_house_not_just_digits() -> None:
"""house='26а' → в запрос уходит ПОЛНЫЙ номер '26а', а не только цифры '26'.
Регрессия-гард на исходный баг: раньше литера отрезалась (`house_digits`
= '26') и в WHERE была опциональна, поэтому «26а» матчился на дом «26».
Цифры остаются отдельным параметром но только как дешёвый prefilter.
"""
params = _house_match_params("26а")
assert params["house_norm"] == "26а"
assert params["house_digits"] == "26" # prefilter only
assert params["street"] == "космонавтов"
# Литера больше не «подсказка для сортировки» — старый параметр ушёл.
assert "house_full" not in params
@pytest.mark.parametrize(
("raw_house", "expected_norm"),
[
("13б", "13б"),
("13 б", "13б"),
("13-б", "13б"),
("13Б", "13б"),
("13 Б", "13б"),
("13", "13"),
],
)
def test_house_match_normalizes_letter_spellings(raw_house: str, expected_norm: str) -> None:
"""«13б» / «13 б» / «13-б» / «13Б» — одна и та же литера, один канон."""
assert _house_match_params(raw_house, street="новгородцевой")["house_norm"] == expected_norm
def test_house_match_sql_compares_house_by_equality() -> None:
"""SQL сравнивает нормализованный номер РАВЕНСТВОМ, а не «литера опциональна».
Структурный гард: если кто-то вернёт матч по цифрам с опциональной литерой
(`[а-яё]?` в WHERE как единственная проверка дома), тест упадёт.
"""
db = MagicMock()
db.execute.return_value.first.return_value = None
_cadastral_house_match(db, "новгородцевой", "13б")
sql = str(db.execute.call_args.args[0])
assert "= CAST(:house_norm AS text)" in sql
# tie-break по литере в ORDER BY больше не решает корректность
assert "house_full" not in sql
# ── geocode() wiring ─────────────────────────────────────────────────────────
@ -604,3 +654,202 @@ async def test_suggest_skips_ekb_local_tier_for_unrecognized_locality(
mock_house.assert_not_called()
mock_forward.assert_not_called()
mock_nominatim.assert_called_once()
# ── House-letter matching semantics ─────────────────────────────────────────
# Само сравнение дома выполняет Postgres, поэтому здесь — зеркало SQL-выражения
# на Python. Паттерн НЕ дублируется: он выводится из той же константы
# `_SQL_HOUSE_TOKEN_RE`, что уходит в запрос (Postgres `\m` = «начало слова»
# ≡ Python `\b` перед словесным символом). Правка SQL-регекспа автоматически
# меняет и эти проверки — рассинхрон невозможен.
# Строки-адреса — реальные формы `readable_address` из gendesign_cad_buildings.
def _sql_house_token(readable_address: str) -> str:
"""Зеркало `_SQL_HOUSE_TOKEN_NORM`: извлечь номер дома и привести к канону."""
py_pattern = _SQL_HOUSE_TOKEN_RE.replace("\\m", "\\b")
m = re.search(py_pattern, readable_address, re.IGNORECASE)
token = (m.group(1) if m else "").lower()
token = re.sub(r"\s", "", token)
return re.sub(r"-([а-яё])", r"\1", token)
@pytest.mark.parametrize(
("readable_address", "expected"),
[
# Литера в трёх написаниях + регистр → один канон
("Свердловская область, г. Екатеринбург, ул. Новгородцевой, д. 7б", "7б"),
("Свердловская область, г. Екатеринбург, ул. Новгородцевой, д. 23-б", "23б"),
("Свердловская область, г. Екатеринбург, ул. X, д. 18 б", "18б"),
("Свердловская область, г. Екатеринбург, ул. X, д. 13Б", "13б"),
# Без литеры
("Свердловская область, г. Екатеринбург, ул. Новгородцевой, д. 13", "13"),
("Российская Федерация, город Екатеринбург, улица Новгородцевой, дом 13", "13"),
("Российская Федерация, город Екатеринбург, улица Малышева, сооружение 30", "30"),
("Российская Федерация, город Екатеринбург, улица Малышева, строение 30 в", "30в"),
# Хвосты, которые литерой НЕ являются
("Свердловская область, г. Екатеринбург, ул. X, д. 11 (кв. 1-150)", "11"),
("Свердловская область, г. Екатеринбург, ул. X, д. 25, корп. 1", "25"),
("Российская Федерация, город Екатеринбург, улица X, дом 102 корпус 1", "102"),
("Свердловская область, г. Екатеринбург, ул. X, д. 16 угол улица Титова", "16"),
# Угловые/корпусные номера — ЧАСТЬ номера, не отбрасываются
("Свердловская область, г. Екатеринбург, ул. X, д. 58/3", "58/3"),
("Свердловская область, г. Екатеринбург, ул. X, д. 36/24а", "36/24а"),
# Дефис перед ЦИФРОЙ не схлопывается (иначе «64-2» стало бы домом «642»)
("Свердловская область, г. Екатеринбург, ул. X, д. 64-2", "64-2"),
("Свердловская область, г. Екатеринбург, ул. X, д. 642", "642"),
# Маркер только с начала слова: «проезд» не даёт дом «8»
("Свердловская область, г Екатеринбург, проезд 8 Марта, д 5", "5"),
("Свердловская область, г Екатеринбург, ул Привокзальная, д 22", "22"),
# Нет дом-маркера → номер не извлекаем (адрес недостижим этим тиром)
("Свердловская область, город Екатеринбург, проезд 4-й ЕКАД Южный", ""),
],
)
def test_sql_house_token_extraction(readable_address: str, expected: str) -> None:
assert _sql_house_token(readable_address) == expected
@pytest.mark.parametrize(
("query_house", "readable_address", "should_match", "label"),
[
# ── Прод-баг #1: «Новгородцевой 13б» отдавал дом 13 как exact ──────
(
"13б",
"Российская Федерация, город Екатеринбург, улица Новгородцевой, дом 13",
False,
"запрос С литерой не берёт дом БЕЗ литеры",
),
# ── Прод-баг #2 (обратный): «Малышева 30» отдавал «д. 30-б» ────────
(
"30",
"Свердловская область, г. Екатеринбург, ул. Малышева, д. 30-б",
False,
"запрос БЕЗ литеры не берёт дом С литерой",
),
(
"7б",
"Свердловская область, г. Екатеринбург, ул. Новгородцевой, д. 7в",
False,
"другая литера не матчится",
),
# ── Позитив: литера совпала во всех написаниях реестра ─────────────
("7б", "Свердловская область, г. Екатеринбург, ул. Новгородцевой, д. 7б", True, "«13б»"),
(
"23б",
"Свердловская область, г. Екатеринбург, ул. Новгородцевой, д. 23-б",
True,
"«13-б»",
),
("18б", "Свердловская область, г. Екатеринбург, ул. X, д. 18 б", True, "«13 б»"),
("13б", "Свердловская область, г. Екатеринбург, ул. X, д. 13Б", True, "регистр"),
# ── Позитив: без литеры ────────────────────────────────────────────
(
"13",
"Российская Федерация, город Екатеринбург, улица Новгородцевой, дом 13",
True,
"«дом N»",
),
(
"30",
"Российская Федерация, город Екатеринбург, улица Малышева, сооружение 30",
True,
"«сооружение N»",
),
# Префикс числа не считается совпадением
("13", "Свердловская область, г. Екатеринбург, ул. X, д. 130", False, "13 ≠ 130"),
# Угловой номер не подменяет простой
("58", "Свердловская область, г. Екатеринбург, ул. X, д. 58/3", False, "58 ≠ 58/3"),
],
)
def test_house_letter_match_semantics(
query_house: str, readable_address: str, should_match: bool, label: str
) -> None:
"""Равенство нормализованных номеров — обе стороны приводятся к одному канону."""
matched = _sql_house_token(readable_address) == _norm_house(query_house)
assert matched is should_match, label
def test_query_letter_house_falls_through_instead_of_returning_neighbour() -> None:
"""Нет дома с литерой → None (не «похожий» дом) → работают следующие тиры.
Ключевое свойство фикса: молчаливая подмена соседнего здания здесь
помечалась бы `confidence="exact"` и кэшировалась на 90 дней.
"""
db = MagicMock()
db.execute.return_value.first.return_value = None # дома «13б» в реестре нет
assert _cadastral_house_match(db, "новгородцевой", "13б") is None
assert db.execute.call_args.args[1]["house_norm"] == "13б"
async def test_geocode_letter_house_miss_reaches_nominatim() -> None:
"""«Новгородцевой 13б» без хита в реестре доходит до Nominatim, а не
возвращает дом 13 с `confidence="exact"`."""
db = MagicMock()
nominatim_result = GeocodeResult(
lat=56.82,
lon=60.68,
full_address="ул. Новгородцевой, 13б, Екатеринбург",
provider="nominatim",
confidence="approximate",
)
with (
patch("app.services.geocoder._cache_get", return_value=None),
patch("app.services.geocoder._geoportal_house_match", return_value=None),
patch("app.services.geocoder._cadastral_house_match", return_value=None) as mock_house,
patch("app.services.geocoder._cadastral_forward_sync", return_value=[]),
patch("app.services.geocoder._cache_put"),
patch(
"app.services.geocoder._nominatim_lookup",
new_callable=AsyncMock,
return_value=nominatim_result,
) as mock_nominatim,
):
result = await geocode("Екатеринбург, Новгородцевой 13б", db)
assert result is not None
assert result.confidence == "approximate"
assert result.lat == pytest.approx(56.82)
mock_house.assert_called_once()
# в матчер ушёл ПОЛНЫЙ номер с литерой
assert mock_house.call_args.args[2] == "13б"
mock_nominatim.assert_called_once()
def test_parse_street_house_keeps_letter_in_all_spellings() -> None:
"""Парсер отдаёт литеру матчеру в каноне — иначе строгий матч бесполезен."""
assert _parse_street_house("Новгородцевой 13б") == ("новгородцевой", "13б")
assert _parse_street_house("Новгородцевой 13-б") == ("новгородцевой", "13б")
assert _parse_street_house("Новгородцевой 13 Б") == ("новгородцевой", "13б")
assert _parse_street_house("ул. Новгородцевой, д. 13б") == ("новгородцевой", "13б")
assert _parse_street_house("Новгородцевой 13") == ("новгородцевой", "13")
# ── DaData region constraint ────────────────────────────────────────────────
def test_dadata_region_constant_has_no_region_type() -> None:
"""DaData `locations.region` сравнивается с именем БЕЗ типа.
«Свердловская область» hard-filter, который не совпадает ни с чем и молча
даёт 0 подсказок (прод-баг). Тип живёт в `region_type`/`region_with_type`.
"""
assert SVERDLOVSK_OBLAST_REGION == "Свердловская"
lowered = SVERDLOVSK_OBLAST_REGION.lower()
for type_word in ("область", "обл", "край", "респ"):
assert type_word not in lowered, f"тип региона {type_word!r} ломает locations-фильтр"
async def test_dadata_suggest_passes_region_without_type() -> None:
"""`_dadata_suggest` отдаёт в DaData именно region-константу (не город)."""
with patch(
"app.services.geocoder.dadata.suggest_addresses",
new_callable=AsyncMock,
return_value=[],
) as mock_suggest:
assert await _dadata_suggest("Новгородцевой 13б", limit=5) == []
kwargs = mock_suggest.call_args.kwargs
assert kwargs["region"] == "Свердловская"
assert kwargs["city"] is None