gendesign/tradein-mvp/backend/tests/test_geocoder_city_hint.py
bot-backend b1727ca39c feat(msk): импорт сырья по Москве в listings и region-aware геокодирование
Три куска, каждый нужен, чтобы поиск и оценка по Москве заработали end-to-end.

1. Импортёр msk_raw -> listings (app/tasks/msk_raw_import.py).

Переиспользует штатный save_listings из кита: писатель уже параметризован
регионом, свой не нужен. payload в msk_raw — сериализованный ScrapedLot
один в один, так что импорт сводится к сборке модели и вызову писателя.

Москва отбирается по префиксу административного округа в адресе, а не по
bbox. Причина: адрес Циан не содержит города, а границы региона 77
захватывают ближний пояс области. Замер по проду: с округом 35 552, все
внутри bbox 77; без округа внутри bbox 17 576 — это область. Отдельно
отсекаются 212 карточек с адресом «Екатеринбург (Cian)», артефакт парсера.

listing_segment ПЕРЕСЧИТЫВАЕТСЯ перед записью, а не копируется из payload.
Кит ставит novostroyki по одному наличию offer.newbuilding.id. Замер по
всем 60 464 карточкам: is_from_developer=true у НУЛЯ, false у 29 000,
отсутствует у 31 464. Застройщик не продаёт ни одной карточки корпуса.
В проде есть гвард (estimator.py): в аналоги идут строки только с
listing_segment IS NULL или 'vtorichka' — копирование метки как есть
выбросило бы 29 000 строк из подбора.

Авито импортируется только с явным --allow-unfiltered: в его адресе нет ни
города, ни округа, координат нет ни у одной из 50 335 карточек, отличить
область от Москвы нечем.

Прогон на проде: прочитано 60 464, записано 35 552, все с геометрией,
35 182 привязаны к дому, создано 10 960 домов. Повторный проход строки не
дублирует — idempotency на dedup_hash, проверено.

2. Подсказки адреса стали региональными (geocoder.suggest, api/v1/geocode).

Раньше suggest вообще не принимал регион: DaData звалась с жёстким
region='Свердловская', Nominatim — с viewbox 66-го и bounded=1. Московский
адрес давал ПУСТОЙ список молча, без ошибки; в коде это уже было описано
как известный баг. Механику по регионам переиспользовали из geocode(),
вторую не писали. Кадастровый тир для не-66 не зовётся: он на ЕКБ-данных.

3. Оценка перестала геокодировать Москву свердловским скоупом (estimator).

geocode() звалась без региона, то есть с дефолтом 66, и московский адрес
возвращал бы пустую оценку с причиной address_not_geocoded даже с рабочими
подсказками. Регион запроса определяется по координатам через реестр, затем
по city_hint, затем дефолт. Fast-path клиентских координат стал
региононезависимым: OBLAST66_BBOX и REGIONS[66].bbox_region совпадают
байт-в-байт, поэтому для 66 поведение прежнее, добавились координаты Москвы.

Регресс-нейтральность по Свердловской области — главный критерий всех трёх
кусков. Тесты: 1160 passed по затронутым областям.

Известные ограничения. Границы 77 захватывают ближний пояс области, Химки
резолвятся в Москву. У региона 77 нет ни одного тира обогащения, оценка
поедет на аналогах и сделках. Ценовая полоса по Москве одна на весь город.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ8jqr4SFirX5tFLwdSrXh
2026-09-10 18:50:37 +03:00

461 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Тесты #2576 — geocoder больше НЕ подставляет "Екатеринбург" молча.
Проблема (issue #2576 / эпик расширения на область): `_nominatim_lookup`,
`_nominatim_suggest` при отсутствии маркера города/области в самом адресе
всегда молча подставляли "Екатеринбург" — житель Нижнего Тагила, вводя
«Ленина, 1», получал уверенно неверную цену по екатеринбургской улице Ленина
(обе улицы называются одинаково).
#2593: Yandex Geocoder (`_yandex_lookup`/`_yandex_suggest`) удалён из geocoder —
их тесты здесь удалены вместе с ним; покрытие тех же сценариев (bias/prefix
без молчаливого "Екатеринбург") осталось на `_nominatim_lookup`.
Покрывают:
- `_resolve_city_for_geocode` — приоритет: маркер в адресе > `city_hint` > None.
- `_nominatim_suggest` — без города НЕ форсит city-суффикс молчаливо (dual-query);
с `city_hint` — суффикс из hint'а; с явным "Екатеринбург" в адресе — поведение
НЕ изменилось (как раньше).
- `geocode()` — `city_ambiguous=True` когда город не указан ни в адресе, ни в
`city_hint`; `False` когда указан явно (текстом или через `city_hint`).
- Cache-ключ (`_cache_key`) — разные `city_hint` для одного текста адреса НЕ
делят одну запись кэша (regression test на cache poisoning).
"""
from __future__ import annotations
import contextlib
import os
from unittest.mock import AsyncMock, MagicMock, patch
os.environ.setdefault("DATABASE_URL", "postgresql://test:test@localhost/test_db")
import pytest
from app.services.geocoder import (
GeocodeResult,
GeocodeSuggestion,
_cache_key,
_nominatim_suggest,
_resolve_city_for_geocode,
geocode,
known_city_hint,
suggest,
)
# ── known_city_hint (#2603) ──────────────────────────────────────────────────
@pytest.mark.parametrize(
"value,expected",
[
# Известные города области — значение проходит КАК ЕСТЬ (регистр/пробелы
# нормализует сам геокодер, caller'ы не должны его причёсывать).
("Екатеринбург", "Екатеринбург"),
("Нижний Тагил", "Нижний Тагил"),
("нижний тагил", "нижний тагил"),
("Каменск-Уральский", "Каменск-Уральский"),
# Мусор из хвоста росреестрового deals.city — это улицы/урочища, а не
# города; хинт из них закрыл бы EKB-тиры и уехал префиксом в запрос.
("Бессонова", None),
("Бердюгина", None),
("Билейский рыбопитомник", None),
# Пусто — хинта нет.
(None, None),
("", None),
(" ", None),
],
)
def test_known_city_hint(value: str | None, expected: str | None) -> None:
assert known_city_hint(value) == expected
# ── _resolve_city_for_geocode ────────────────────────────────────────────────
@pytest.mark.parametrize(
"address,city_hint,expected",
[
# Ни маркер, ни hint — раньше здесь молча подставлялся "Екатеринбург".
("Ленина, 1", None, (None, False)),
("Ленина, 1", "", (None, False)),
("Ленина, 1", " ", (None, False)),
# city_hint передан явно вызывающим кодом.
("Ленина, 1", "Нижний Тагил", ("Нижний Тагил", True)),
# Маркер уже в адресе — hint игнорируется (marker имеет приоритет).
("Нижний Тагил, Ленина, 1", "Серов", (None, True)),
("Екатеринбург, Малышева 30", None, (None, True)),
("Екатеринбург, Малышева 30", "Серов", (None, True)),
],
)
def test_resolve_city_for_geocode(
address: str, city_hint: str | None, expected: tuple[str | None, bool]
) -> None:
assert _resolve_city_for_geocode(address, city_hint) == expected
# ── _cache_key — cache poisoning между городами ──────────────────────────────
def test_cache_key_without_hint_unchanged() -> None:
"""Без city_hint формат ключа НЕ меняется — backward-compat с накопленным кэшем."""
assert _cache_key("ленина, 1", None) == "ленина, 1"
assert _cache_key("ленина, 1", "") == "ленина, 1"
def test_cache_key_different_cities_do_not_collide() -> None:
"""#2576: разные города для одного текста адреса — разные cache-ключи."""
key_tagil = _cache_key("ленина, 1", "Нижний Тагил")
key_ekb = _cache_key("ленина, 1", "Екатеринбург")
key_none = _cache_key("ленина, 1", None)
assert key_tagil != key_ekb
assert key_tagil != key_none
assert key_ekb != key_none
def test_cache_key_hint_normalized() -> None:
"""city_hint нормализуется (case/whitespace) — не создаёт лишних ключей."""
assert _cache_key("ленина, 1", "Нижний Тагил") == _cache_key("ленина, 1", "нижний тагил ")
# ── _nominatim_suggest ───────────────────────────────────────────────────────
async def test_nominatim_suggest_no_city_dual_query_both_variants_sent() -> None:
"""#2580 (C2): без города — Nominatim получает ОБА запроса: bare (честный,
без города) И ЕКБ-suffixed (majority-трафик). Не подмена одним вариантом —
объединение (см. `test_nominatim_suggest_pobedy20_ekb_result_not_lost` ниже
— чистое удаление суффикса теряло реальные ЕКБ-адреса).
"""
calls: list[str] = []
async def fake_query_multi(client, query, limit, **_kw):
calls.append(query)
return []
with (
patch(
"app.services.geocoder._nominatim_query_multi",
new=AsyncMock(side_effect=fake_query_multi),
),
patch("app.services.geocoder.asyncio.sleep", new=AsyncMock()),
):
result = await _nominatim_suggest("Ленина, 1")
assert result == []
assert "Ленина, 1" in calls # bare — честный, без города
assert "Ленина, 1, Екатеринбург" in calls # ЕКБ-вариант — не потерян
async def test_nominatim_suggest_city_hint_suffix() -> None:
calls: list[str] = []
async def fake_query_multi(client, query, limit, **_kw):
calls.append(query)
return []
with patch(
"app.services.geocoder._nominatim_query_multi", new=AsyncMock(side_effect=fake_query_multi)
):
await _nominatim_suggest("Ленина, 1", city_hint="Нижний Тагил")
assert calls[0] == "Ленина, 1, Нижний Тагил"
async def test_nominatim_suggest_explicit_ekb_unchanged() -> None:
calls: list[str] = []
async def fake_query_multi(client, query, limit, **_kw):
calls.append(query)
return []
with patch(
"app.services.geocoder._nominatim_query_multi", new=AsyncMock(side_effect=fake_query_multi)
):
await _nominatim_suggest("Екатеринбург, Малышева 30")
assert calls[0] == "Екатеринбург, Малышева 30"
async def test_nominatim_suggest_pobedy20_ekb_result_not_lost() -> None:
"""#2580 (C2) regression — "Победы 20" (реальный кейс с прода, подтверждён
ревьюером): без города ЕКБ-кандидат ('Парк Победы' ЖК, Екатеринбург) должен
остаться в подсказках, НЕ потеряться в пользу похожего street-level матча
в Верхней Пышме.
Симулирует реальные координаты:
'Победы 20, Екатеринбург' → 56.899, 60.579 (ЖК "Парк Победы", Екатеринбург)
'Победы 20' → 56.964, 60.610 (ул. Победы, Верхняя Пышма)
"""
ekb_item = {
"place_id": 1001,
"lat": "56.899",
"lon": "60.579",
"display_name": 'ЖК "Парк Победы", Орджоникидзевский район, Екатеринбург',
"address": {"road": "Победы", "house_number": "20", "suburb": "Орджоникидзевский район"},
}
pyshma_item = {
"place_id": 1002,
"lat": "56.964",
"lon": "60.610",
"display_name": "улица Победы, 20, Верхняя Пышма",
"address": {"road": "улица Победы", "house_number": "20"},
}
async def fake_query_multi(client, query, limit, **_kw):
if query.endswith(", Екатеринбург"):
return [ekb_item]
return [pyshma_item]
with (
patch(
"app.services.geocoder._nominatim_query_multi",
new=AsyncMock(side_effect=fake_query_multi),
),
patch("app.services.geocoder.asyncio.sleep", new=AsyncMock()),
):
result = await _nominatim_suggest("Победы 20")
assert result, "ожидались подсказки"
ekb_hits = [r for r in result if r.lat == pytest.approx(56.899)]
assert ekb_hits, "ЕКБ-кандидат ('Парк Победы') должен остаться в подсказках, не потеряться"
# ЕКБ-кандидат идёт первым (majority-трафик — привычный порядок для основных пользователей).
assert result[0].lat == pytest.approx(56.899)
# Верхняя Пышма тоже осталась в списке — honest alternative, не подменена.
pyshma_hits = [r for r in result if r.lat == pytest.approx(56.964)]
assert pyshma_hits, "не-ЕКБ кандидат тоже должен остаться (объединение, не замена)"
async def test_nominatim_suggest_dedupe_across_dual_query() -> None:
"""Если bare и ЕКБ-suffixed запросы возвращают ОДИН и тот же item (по place_id)
— он не дублируется в итоговом списке подсказок."""
same_item = {
"place_id": 42,
"lat": "56.838",
"lon": "60.605",
"display_name": "ул. Малышева, 30, Екатеринбург",
"address": {"road": "ул. Малышева", "house_number": "30"},
}
async def fake_query_multi(client, query, limit, **_kw):
return [same_item]
with (
patch(
"app.services.geocoder._nominatim_query_multi",
new=AsyncMock(side_effect=fake_query_multi),
),
patch("app.services.geocoder.asyncio.sleep", new=AsyncMock()),
):
result = await _nominatim_suggest("Малышева 30")
assert len(result) == 1, "одинаковый place_id из обоих запросов не должен дублироваться"
# ── geocode() — city_ambiguous flag ──────────────────────────────────────────
def _geocode_patches(nominatim_result: GeocodeResult | None):
return (
patch("app.services.geocoder._cache_get", return_value=None),
patch("app.services.geocoder._cache_put"),
patch("app.services.geocoder._geoportal_house_match", return_value=None),
patch("app.services.geocoder._cadastral_house_match", return_value=None),
patch("app.services.geocoder._cadastral_forward_sync", return_value=[]),
patch(
"app.services.geocoder._nominatim_lookup", new=AsyncMock(return_value=nominatim_result)
),
)
# ── C1 (#2580) — city_hint должен доходить до локальных ЕКБ-only тиров ──────
async def test_geocode_city_hint_non_ekb_skips_local_ekb_tiers() -> None:
"""#2580 (C1): city_hint="Нижний Тагил" должен ЗАПРЕТИТЬ geoportal/cadastral
(ЕКБ-only базы) — иначе они возвращают "точный" ЕКБ-хит для улицы, которая
совпадает по названию, а `city_ambiguous=False` (хинт был!) делает такой
неверный результат ложно-уверенным. Мок geoportal нарочно возвращает ЕКБ-хит
(как в проде) — фикс должен НЕ дать ему сработать вообще.
"""
db = MagicMock()
ekb_hit = GeocodeSuggestion(
label="Ленина, 1, Екатеринбург",
full_address="Ленина, 1, Екатеринбург",
lat=56.83788,
lon=60.58018,
kind="house",
)
tagil_result = GeocodeResult(
lat=57.905, lon=59.950, full_address="Ленина, 1, Нижний Тагил", provider="nominatim"
)
with contextlib.ExitStack() as stack:
stack.enter_context(patch("app.services.geocoder._cache_get", return_value=None))
stack.enter_context(patch("app.services.geocoder._cache_put"))
geoportal_mock = stack.enter_context(
patch("app.services.geocoder._geoportal_house_match", return_value=ekb_hit)
)
cadastral_mock = stack.enter_context(
patch("app.services.geocoder._cadastral_house_match", return_value=ekb_hit)
)
stack.enter_context(patch("app.services.geocoder._cadastral_forward_sync", return_value=[]))
stack.enter_context(
patch(
"app.services.geocoder._nominatim_lookup",
new=AsyncMock(return_value=tagil_result),
)
)
result = await geocode("Ленина, 1", db, city_hint="Нижний Тагил")
geoportal_mock.assert_not_called()
cadastral_mock.assert_not_called()
assert result is not None
assert result.lat == pytest.approx(57.905) # Тагил, НЕ подставленный ЕКБ-хит (56.838)
assert result.lat != pytest.approx(56.83788)
async def test_geocode_real_ekb_address_still_uses_local_tiers() -> None:
"""Сквозной кейс: реальный ЕКБ-адрес БЕЗ city_hint по-прежнему резолвится через
локальный geoportal-тир (ЕКБ-путь не деградировал после C1-фикса)."""
db = MagicMock()
ekb_hit = GeocodeSuggestion(
label="Малышева, 30, Екатеринбург",
full_address="Малышева, 30, Екатеринбург",
lat=56.8389,
lon=60.6057,
kind="house",
)
with contextlib.ExitStack() as stack:
stack.enter_context(patch("app.services.geocoder._cache_get", return_value=None))
stack.enter_context(patch("app.services.geocoder._cache_put"))
geoportal_mock = stack.enter_context(
patch("app.services.geocoder._geoportal_house_match", return_value=ekb_hit)
)
result = await geocode("Малышева, 30", db)
geoportal_mock.assert_called_once()
assert result is not None
assert result.lat == pytest.approx(56.8389)
assert result.confidence == "exact"
assert result.city_ambiguous is True # город не указан — честный флаг
async def test_suggest_city_hint_non_ekb_skips_cadastral_tier1() -> None:
"""#2580 (C1): suggest(city_hint="Нижний Тагил") — Tier 1 (кадастр ЕКБ) НЕ должен
вызываться (раньше был мёртвым параметром для этого тира)."""
db = MagicMock()
with contextlib.ExitStack() as stack:
house_mock = stack.enter_context(patch("app.services.geocoder._cadastral_house_match"))
forward_mock = stack.enter_context(patch("app.services.geocoder._cadastral_forward_sync"))
mock_settings = stack.enter_context(patch("app.services.geocoder.settings"))
mock_settings.dadata_api_token = None
stack.enter_context(
patch("app.services.geocoder._nominatim_suggest", new=AsyncMock(return_value=[]))
)
await suggest("Ленина, 1", db=db, city_hint="Нижний Тагил")
house_mock.assert_not_called()
forward_mock.assert_not_called()
async def test_geocode_city_ambiguous_true_when_no_city_known() -> None:
"""Ни адрес, ни city_hint не называют город → city_ambiguous=True."""
db = MagicMock()
nominatim_result = GeocodeResult(
lat=56.838, lon=60.605, full_address="что-то", provider="nominatim"
)
with contextlib.ExitStack() as stack:
for cm in _geocode_patches(nominatim_result):
stack.enter_context(cm)
result = await geocode("Малышева, 30", db)
assert result is not None
assert result.city_ambiguous is True
async def test_geocode_city_ambiguous_false_when_marker_present() -> None:
"""Явный "Екатеринбург" в адресе → город указан пользователем → city_ambiguous=False."""
db = MagicMock()
nominatim_result = GeocodeResult(
lat=56.838, lon=60.605, full_address="Екатеринбург, Малышева, 30", provider="nominatim"
)
with contextlib.ExitStack() as stack:
for cm in _geocode_patches(nominatim_result):
stack.enter_context(cm)
result = await geocode("Екатеринбург, Малышева, 30", db)
assert result is not None
assert result.city_ambiguous is False
async def test_geocode_city_ambiguous_false_when_city_hint_given() -> None:
"""city_hint передан вызывающим кодом → город указан → city_ambiguous=False."""
db = MagicMock()
nominatim_result = GeocodeResult(
lat=57.905, lon=59.950, full_address="Нижний Тагил, Ленина, 1", provider="nominatim"
)
with contextlib.ExitStack() as stack:
for cm in _geocode_patches(nominatim_result):
stack.enter_context(cm)
result = await geocode("Ленина, 1", db, city_hint="Нижний Тагил")
assert result is not None
assert result.city_ambiguous is False
# ── geocode() — cache не смешивает города ────────────────────────────────────
async def test_geocode_cache_does_not_mix_cities() -> None:
"""#2576 regression: два города для одного текста адреса не делят cache-запись.
Без city_hint-aware ключа второй вызов (Тагил) читал бы уже закэшированный
(первым вызовом, ЕКБ) результат — координаты ЕКБ вместо Тагила.
"""
store: dict[str, GeocodeResult] = {}
def fake_cache_get(db, addr_norm):
return store.get(addr_norm)
def fake_cache_put(db, addr_norm, result):
store[addr_norm] = result
async def fake_nominatim_lookup(address, city_hint=None, region_code=66):
if city_hint == "Нижний Тагил":
return GeocodeResult(
lat=57.905, lon=59.950, full_address="Нижний Тагил, Ленина, 1", provider="nominatim"
)
return GeocodeResult(
lat=56.838, lon=60.605, full_address="Екатеринбург, Ленина, 1", provider="nominatim"
)
db = MagicMock()
with contextlib.ExitStack() as stack:
stack.enter_context(patch("app.services.geocoder._cache_get", side_effect=fake_cache_get))
stack.enter_context(patch("app.services.geocoder._cache_put", side_effect=fake_cache_put))
stack.enter_context(
patch("app.services.geocoder._geoportal_house_match", return_value=None)
)
stack.enter_context(
patch("app.services.geocoder._cadastral_house_match", return_value=None)
)
stack.enter_context(patch("app.services.geocoder._cadastral_forward_sync", return_value=[]))
stack.enter_context(
patch(
"app.services.geocoder._nominatim_lookup",
new=AsyncMock(side_effect=fake_nominatim_lookup),
)
)
r_ekb = await geocode("Ленина, 1", db, city_hint="Екатеринбург")
r_tagil = await geocode("Ленина, 1", db, city_hint="Нижний Тагил")
# Повторный запрос ЕКБ — должен снова попасть в СВОЙ кэш (не Тагила).
r_ekb_again = await geocode("Ленина, 1", db, city_hint="Екатеринбург")
assert r_ekb is not None and r_tagil is not None and r_ekb_again is not None
assert r_ekb.lat == pytest.approx(56.838)
assert r_tagil.lat == pytest.approx(57.905)
assert r_ekb_again.lat == pytest.approx(56.838)
assert r_ekb.lat != r_tagil.lat
# Два разных ключа реально осели в fake-store (не перезаписали друг друга).
assert len(store) == 2