feat(tradein/geo): реестр регионов — единственный источник гео-границ (#3051, шард 1a) #3116
6 changed files with 391 additions and 88 deletions
|
|
@ -27,6 +27,9 @@ from tenacity import retry, stop_after_attempt, wait_exponential
|
|||
|
||||
from app.core.config import settings
|
||||
from app.services import dadata
|
||||
from app.services.regions import REGIONS as _ALL_REGIONS
|
||||
|
||||
_REGION_66 = _ALL_REGIONS[66]
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -110,8 +113,10 @@ class GeocodeResult:
|
|||
# Питер/Тюмень/Уфу (#1871). WIDE строго содержит TIGHT, поэтому всё, что
|
||||
# прошло бы tight-фильтр, проходит и wide-guard.
|
||||
# bbox = (lat_min, lat_max, lon_min, lon_max).
|
||||
EKB_BBOX_TIGHT = (56.65, 56.95, 60.40, 60.85)
|
||||
EKB_BBOX_WIDE = (56.6, 57.1, 60.3, 60.9)
|
||||
# #3051: значения живут в реестре регионов (app.services.regions, регион 66) —
|
||||
# имена остаются здесь ради существующих импортёров и читаемости вызовов.
|
||||
EKB_BBOX_TIGHT = _REGION_66.bbox_tight
|
||||
EKB_BBOX_WIDE = _REGION_66.bbox_wide
|
||||
|
||||
|
||||
def is_within_ekb_bbox(
|
||||
|
|
@ -140,7 +145,7 @@ def is_within_ekb_bbox_wide(lat: float, lon: float) -> bool:
|
|||
# проверки region==66. Дороже false-positive у самой границы соседних регионов,
|
||||
# дешевле false-negative (выбросить корректный областной адрес — исходный баг).
|
||||
# bbox = (lat_min, lat_max, lon_min, lon_max).
|
||||
OBLAST66_BBOX = (55.8, 62.2, 56.7, 66.6)
|
||||
OBLAST66_BBOX = _REGION_66.bbox_region # #3051: значение — в реестре регионов
|
||||
|
||||
# Nominatim viewport для областного (не только ЕКБ) поиска — покрывает всю
|
||||
# область. viewbox — Nominatim-формат (lon1,lat1,lon2,lat2, NW→SE); ll/spn —
|
||||
|
|
@ -162,54 +167,7 @@ def is_within_oblast66_bbox(lat: float, lon: float) -> bool:
|
|||
# городов), покрывает основные региональные центры. Используется чтобы не
|
||||
# навязывать "Екатеринбург, " в запрос, если пользователь уже указал другой
|
||||
# город региона (геокодер forward/suggest prefix-логика).
|
||||
SVERDLOVSK_OBLAST_CITIES = frozenset(
|
||||
{
|
||||
"екатеринбург",
|
||||
"нижний тагил",
|
||||
"каменск-уральский",
|
||||
"первоуральск",
|
||||
"серов",
|
||||
"новоуральск",
|
||||
"ревда",
|
||||
"полевской",
|
||||
"асбест",
|
||||
"богданович",
|
||||
"ирбит",
|
||||
"красноуфимск",
|
||||
"верхняя пышма",
|
||||
"берёзовский",
|
||||
"березовский",
|
||||
"заречный",
|
||||
"качканар",
|
||||
"краснотурьинск",
|
||||
"североуральск",
|
||||
"ивдель",
|
||||
"тавда",
|
||||
"туринск",
|
||||
"сысерть",
|
||||
"среднеуральск",
|
||||
"дегтярск",
|
||||
"верхняя салда",
|
||||
"нижняя салда",
|
||||
"невьянск",
|
||||
"артёмовский",
|
||||
"артемовский",
|
||||
"камышлов",
|
||||
"алапаевск",
|
||||
"сухой лог",
|
||||
"кушва",
|
||||
"красноуральск",
|
||||
"карпинск",
|
||||
"нижняя тура",
|
||||
"верхний тагил",
|
||||
"нижние серги",
|
||||
# НЕ добавляем "лесной": ekb_geoportal_buildings содержит реальную ЕКБ-улицу
|
||||
# «Лесной» (3 дома) — точное совпадение целого слова, не отличимое от
|
||||
# города ЗАТО Лесной word-boundary'ом (в отличие от «Серова»/«Серов»,
|
||||
# где разные словоформы). Бары-адрес "Лесной, 5" без "Екатеринбург" и
|
||||
# без district-префикса ложно ушёл бы в non-EKB gate.
|
||||
}
|
||||
)
|
||||
SVERDLOVSK_OBLAST_CITIES = _REGION_66.cities # #3051: список — в реестре регионов
|
||||
|
||||
|
||||
def known_city_hint(value: str | None) -> str | None:
|
||||
|
|
|
|||
|
|
@ -52,25 +52,27 @@ from sqlalchemy import text
|
|||
# #2660: окно свежести берём ИЗ эстиматора — единственное определение в проекте.
|
||||
# Дублировать значение здесь нельзя: две константы разъедутся при первой же
|
||||
# перекалибровке, и витрина начнёт показывать другой пул, чем считает цена.
|
||||
from app.services import regions as regions_mod
|
||||
from app.services.estimator import LISTINGS_FRESH_DAYS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── Гео-охват продукта: только Екатеринбург ──────────────────────────────────
|
||||
# Тот же bbox, что EKB_BBOX в backend/app/services/site_finder/poi_loader.py (main
|
||||
# gendesign backend, Overpass-загрузчик osm_poi_ekb) и что использовался при аудите
|
||||
# (1500 адресов / 4000 активных лотов / 2787 POI, все — "по Екатеринбургу"). tradein-mvp —
|
||||
# отдельный деплой/venv от backend/, поэтому константа продублирована, не импортирована;
|
||||
# при изменении bbox в одном месте — проверить и второе (комментарий в обе стороны).
|
||||
_EKB_BBOX_SOUTH = 56.70
|
||||
_EKB_BBOX_WEST = 60.50
|
||||
_EKB_BBOX_NORTH = 56.95
|
||||
_EKB_BBOX_EAST = 60.75
|
||||
# ── Гео-охват продукта ────────────────────────────────────────────────────────
|
||||
# #3051: границы живут в реестре регионов (app.services.regions), НЕ здесь.
|
||||
# У региона 66 bbox_product_core байт-в-байт равен прежним литералам этого файла
|
||||
# (56.70..56.95 / 60.50..60.75, синхронизирован с EKB_BBOX Overpass-загрузчика
|
||||
# POI основного gendesign-бэкенда — комментарий в обе стороны остаётся в
|
||||
# реестре). Точка Москвы теперь попадает в покрытие региона 77 — медианы и POI
|
||||
# считаются в ЕГО ядре (сегодня листингов Москвы нет → честный
|
||||
# insufficient_data, не ложный out_of_coverage).
|
||||
|
||||
|
||||
def _in_ekb_bbox(lat: float, lon: float) -> bool:
|
||||
"""True если точка внутри гео-охвата продукта (Екатеринбург)."""
|
||||
return _EKB_BBOX_SOUTH <= lat <= _EKB_BBOX_NORTH and _EKB_BBOX_WEST <= lon <= _EKB_BBOX_EAST
|
||||
def _covered_region(lat: float, lon: float) -> regions_mod.Region | None:
|
||||
"""Регион, в чьём продукт-ядре лежит точка; None — вне покрытия продукта."""
|
||||
region = regions_mod.region_for_point(lat, lon)
|
||||
if region is not None and regions_mod.is_within_bbox(lat, lon, region.bbox_product_core):
|
||||
return region
|
||||
return None
|
||||
|
||||
|
||||
# ── Калибровочные константы (радиус / минимальная выборка) ──────────────────
|
||||
|
|
@ -277,7 +279,10 @@ _NEAREST_POI_SQL = text(
|
|||
)
|
||||
|
||||
|
||||
def _local_median_ppm2(db: Any, lat: float, lon: float, radius_m: int) -> tuple[float | None, int]:
|
||||
def _local_median_ppm2(
|
||||
db: Any, lat: float, lon: float, radius_m: int, region: regions_mod.Region
|
||||
) -> tuple[float | None, int]:
|
||||
lat_min, lat_max, lon_min, lon_max = region.bbox_product_core
|
||||
row = (
|
||||
db.execute(
|
||||
_MEDIAN_PPM2_LOCAL_SQL,
|
||||
|
|
@ -288,10 +293,10 @@ def _local_median_ppm2(db: Any, lat: float, lon: float, radius_m: int) -> tuple[
|
|||
"fresh_days": LISTINGS_FRESH_DAYS,
|
||||
"price_min": _PRICE_PER_M2_SANITY_MIN,
|
||||
"price_max": _PRICE_PER_M2_SANITY_MAX,
|
||||
"bbox_south": _EKB_BBOX_SOUTH,
|
||||
"bbox_north": _EKB_BBOX_NORTH,
|
||||
"bbox_west": _EKB_BBOX_WEST,
|
||||
"bbox_east": _EKB_BBOX_EAST,
|
||||
"bbox_south": lat_min,
|
||||
"bbox_north": lat_max,
|
||||
"bbox_west": lon_min,
|
||||
"bbox_east": lon_max,
|
||||
},
|
||||
)
|
||||
.mappings()
|
||||
|
|
@ -303,7 +308,8 @@ def _local_median_ppm2(db: Any, lat: float, lon: float, radius_m: int) -> tuple[
|
|||
return (float(median) if median is not None else None), int(row["n"] or 0)
|
||||
|
||||
|
||||
def _citywide_median_ppm2(db: Any) -> tuple[float | None, int]:
|
||||
def _citywide_median_ppm2(db: Any, region: regions_mod.Region) -> tuple[float | None, int]:
|
||||
lat_min, lat_max, lon_min, lon_max = region.bbox_product_core
|
||||
row = (
|
||||
db.execute(
|
||||
_MEDIAN_PPM2_CITYWIDE_SQL,
|
||||
|
|
@ -311,10 +317,10 @@ def _citywide_median_ppm2(db: Any) -> tuple[float | None, int]:
|
|||
"fresh_days": LISTINGS_FRESH_DAYS,
|
||||
"price_min": _PRICE_PER_M2_SANITY_MIN,
|
||||
"price_max": _PRICE_PER_M2_SANITY_MAX,
|
||||
"bbox_south": _EKB_BBOX_SOUTH,
|
||||
"bbox_north": _EKB_BBOX_NORTH,
|
||||
"bbox_west": _EKB_BBOX_WEST,
|
||||
"bbox_east": _EKB_BBOX_EAST,
|
||||
"bbox_south": lat_min,
|
||||
"bbox_north": lat_max,
|
||||
"bbox_west": lon_min,
|
||||
"bbox_east": lon_max,
|
||||
},
|
||||
)
|
||||
.mappings()
|
||||
|
|
@ -398,9 +404,11 @@ def compute_location_index(
|
|||
меньше MIN_SAMPLE_SIZE (или городская выборка-эталон сама вырождена).
|
||||
- "ok" — location_index_pct надёжен.
|
||||
"""
|
||||
if not _in_ekb_bbox(lat, lon):
|
||||
region = _covered_region(lat, lon)
|
||||
if region is None:
|
||||
logger.info(
|
||||
"location_index: lat=%.5f lon=%.5f outside EKB coverage bbox — out_of_coverage",
|
||||
"location_index: lat=%.5f lon=%.5f вне продукт-ядра всех регионов "
|
||||
"покрытия (#3051) — out_of_coverage",
|
||||
lat,
|
||||
lon,
|
||||
)
|
||||
|
|
@ -417,7 +425,7 @@ def compute_location_index(
|
|||
|
||||
nearby_poi, poi_status = _fetch_nearby_poi(db, lat, lon, poi_radius_m, poi_top_n)
|
||||
|
||||
city_median, city_n = _citywide_median_ppm2(db)
|
||||
city_median, city_n = _citywide_median_ppm2(db, region)
|
||||
if city_median is None or city_n < MIN_SAMPLE_SIZE:
|
||||
logger.warning(
|
||||
"location_index: citywide reference sample too small (n=%d) — insufficient_data",
|
||||
|
|
@ -439,7 +447,7 @@ def compute_location_index(
|
|||
sample_size = 0
|
||||
used_radius = radii[-1]
|
||||
for r in radii:
|
||||
local_median, sample_size = _local_median_ppm2(db, lat, lon, r)
|
||||
local_median, sample_size = _local_median_ppm2(db, lat, lon, r, region)
|
||||
used_radius = r
|
||||
if sample_size >= MIN_SAMPLE_SIZE:
|
||||
break
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ import hashlib
|
|||
import re
|
||||
import unicodedata
|
||||
|
||||
from app.services.regions import REGIONS as _REGIONS
|
||||
|
||||
# Strip punctuation EXCEPT hyphens — keep hyphens so abbreviation rules like
|
||||
# 'пр-кт', 'б-р', 'пр-д' can match before they are collapsed to spaces.
|
||||
_PUNCT = re.compile(r"[^\w\s\-]", flags=re.UNICODE)
|
||||
|
|
@ -114,7 +116,11 @@ def house_number_token(normalized: str | None) -> str | None:
|
|||
|
||||
# Екатеринбург — the home city; every other _CITY_TOKENS entry is a non-ЕКБ обл.66 city.
|
||||
# The Tier-2a oblast guard treats a resolved non-ЕКБ city specially (see resolve_city_token).
|
||||
EKB_CITY_TOKEN = "екатеринбург"
|
||||
# #3051: значение — city_token региона 66 из реестра (app.services.regions);
|
||||
# _CITY_TOKENS ниже НЕ из реестра намеренно — это зеркало sweep-раскатки
|
||||
# (CITY_ANCHORS в scraper_kit.orchestration.pipeline, «какие города скрапим»),
|
||||
# а реестр отвечает на «какие города узнаём» — списки эволюционируют раздельно.
|
||||
EKB_CITY_TOKEN = _REGIONS[66].city_token
|
||||
|
||||
# Cities covered by the обл.66 sweep rollout (ЕКБ + oblast per-city schedules).
|
||||
# Normalized form: lowercase, hyphens collapsed to spaces (mirrors normalize_address,
|
||||
|
|
|
|||
191
tradein-mvp/backend/app/services/regions.py
Normal file
191
tradein-mvp/backend/app/services/regions.py
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
"""Реестр регионов покрытия (#3051, часть 1/3 эпика #2989 «Москва»).
|
||||
|
||||
ЕДИНСТВЕННОЕ место, где живут границы покрытия: bbox'ы, города, city-token и
|
||||
набор доступных тиров обогащения. До реестра границы лежали константами в трёх
|
||||
файлах (location_index / geocoder / matching.normalize), и каждая молча
|
||||
отвергла бы Москву; расширение покрытия означало правку россыпи литералов
|
||||
(ровно так дыра #2656 в свежести и появилась — «в главном месте есть, в
|
||||
соседних забыли»).
|
||||
|
||||
Регион 50 (Московская область) отложен сознательно — обоснование в #2996:
|
||||
10 121 текстовое имя города против 612 у Москвы, вся мина имён — в области.
|
||||
|
||||
Модуль — ЛИСТ дерева импортов: не импортирует ничего из app.* (его читают
|
||||
geocoder / location_index / matching.normalize, циклы недопустимы).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
# bbox = (lat_min, lat_max, lon_min, lon_max) — тот же порядок, что исторический
|
||||
# geocoder.EKB_BBOX_TIGHT (см. is_within_bbox ниже).
|
||||
BBox = tuple[float, float, float, float]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Region:
|
||||
"""Один регион покрытия продукта.
|
||||
|
||||
bbox_tight — ядро города: geocoder-фильтрация фуззи-матчей провайдеров
|
||||
(не принять соседний город за совпадение по опечатке).
|
||||
bbox_wide — город + легитимное приграничье: ingest-guard координат,
|
||||
ПРИШЕДШИХ ИЗВНЕ (detail-страницы площадок). Содержит tight.
|
||||
bbox_region — генеральный bbox региона: fallback-accept для провайдеров без
|
||||
структурного region-поля. Содержит wide.
|
||||
bbox_product_core — гео-охват ПРОДУКТА в этом регионе: location_index
|
||||
считает свои медианы/POI только внутри него, точка вне —
|
||||
out_of_coverage. У 66 УЖЕ (не равен) tight: исторический bbox
|
||||
location_index (56.70..56.95/60.50..60.75), синхронизирован с
|
||||
EKB_BBOX Overpass-загрузчика POI основного gendesign-бэкенда
|
||||
(комментарий в обе стороны, см. site_finder/poi_loader.py).
|
||||
city_token — нормализованный токен главного города (нижний регистр, е==ё
|
||||
нормализует потребитель — matching.normalize).
|
||||
cities — узнаваемые города региона (для city_hint / prefix-логики
|
||||
геокодера). НЕ исчерпывающий список — основные центры.
|
||||
enrichment_tiers — какие тиры обогащения РЕАЛЬНО доступны региону.
|
||||
Регион без тира должен деградировать ЯВНО (потребитель
|
||||
спрашивает unsupported_tier_reason и логирует/маркирует),
|
||||
а не молча считать дальше без источника.
|
||||
"""
|
||||
|
||||
code: int
|
||||
name: str
|
||||
bbox_tight: BBox
|
||||
bbox_wide: BBox
|
||||
bbox_region: BBox
|
||||
bbox_product_core: BBox
|
||||
city_token: str
|
||||
cities: frozenset[str]
|
||||
enrichment_tiers: frozenset[str]
|
||||
|
||||
|
||||
def is_within_bbox(lat: float, lon: float, bbox: BBox) -> bool:
|
||||
"""True если (lat, lon) внутри bbox (inclusive)."""
|
||||
lat_min, lat_max, lon_min, lon_max = bbox
|
||||
return lat_min <= lat <= lat_max and lon_min <= lon <= lon_max
|
||||
|
||||
|
||||
# Тиры обогащения (строковые ключи — по label'ам _with_budget в estimator).
|
||||
TIER_AVITO_IMV = "avito_imv"
|
||||
TIER_YANDEX_VALUATION = "yandex_valuation"
|
||||
TIER_CIAN_VALUATION = "cian_valuation"
|
||||
TIER_QUARTER_INDEX = "quarter_index"
|
||||
TIER_SBER_INDEX = "sber_index"
|
||||
|
||||
REGIONS: dict[int, Region] = {
|
||||
66: Region(
|
||||
code=66,
|
||||
name="Свердловская область",
|
||||
# Значения — байт-в-байт прежние литералы из location_index/geocoder
|
||||
# (менять их здесь = менять поведение, это отдельное решение):
|
||||
# tight был geocoder.EKB_BBOX_TIGHT, wide — EKB_BBOX_WIDE,
|
||||
# region — OBLAST66_BBOX, product_core — bbox location_index.
|
||||
bbox_tight=(56.65, 56.95, 60.40, 60.85),
|
||||
bbox_wide=(56.6, 57.1, 60.3, 60.9),
|
||||
bbox_region=(55.8, 62.2, 56.7, 66.6),
|
||||
bbox_product_core=(56.70, 56.95, 60.50, 60.75),
|
||||
city_token="екатеринбург",
|
||||
cities=frozenset(
|
||||
{
|
||||
"екатеринбург",
|
||||
"нижний тагил",
|
||||
"каменск-уральский",
|
||||
"первоуральск",
|
||||
"верхняя пышма",
|
||||
"серов",
|
||||
"новоуральск",
|
||||
"асбест",
|
||||
"полевской",
|
||||
"ревда",
|
||||
"берёзовский",
|
||||
"березовский",
|
||||
"среднеуральск",
|
||||
"верхняя салда",
|
||||
"качканар",
|
||||
"краснотурьинск",
|
||||
"алапаевск",
|
||||
"ирбит",
|
||||
"сухой лог",
|
||||
"богданович",
|
||||
"заречный",
|
||||
"невьянск",
|
||||
"сысерть",
|
||||
"арамиль",
|
||||
}
|
||||
),
|
||||
enrichment_tiers=frozenset(
|
||||
{
|
||||
TIER_AVITO_IMV,
|
||||
TIER_YANDEX_VALUATION,
|
||||
TIER_CIAN_VALUATION,
|
||||
TIER_QUARTER_INDEX,
|
||||
TIER_SBER_INDEX,
|
||||
}
|
||||
),
|
||||
),
|
||||
77: Region(
|
||||
code=77,
|
||||
name="Москва",
|
||||
# Ядро — Москва в пределах МКАД (55.57..55.91 / 37.36..37.85) с тем же
|
||||
# запасом на фуззи, что у ЕКБ-tight относительно города.
|
||||
bbox_tight=(55.55, 55.95, 37.30, 37.90),
|
||||
# Продукт-ядро Москвы = tight (МКАД): медианы/POI считаем в ядре,
|
||||
# Новая Москва дозреет отдельным решением вместе с данными.
|
||||
# Wide — МКАД + ближний пояс (Зеленоград ловится region-bbox'ом).
|
||||
bbox_wide=(55.45, 56.05, 37.10, 38.10),
|
||||
# Генеральный — Москва в административных границах, включая Новую
|
||||
# Москву (юго-запад до ~55.14) и Зеленоград (север до ~56.05).
|
||||
bbox_region=(55.10, 56.10, 36.80, 38.10),
|
||||
bbox_product_core=(55.55, 55.95, 37.30, 37.90),
|
||||
city_token="москва",
|
||||
cities=frozenset({"москва", "зеленоград", "троицк", "щербинка", "московский"}),
|
||||
# Тиров обогащения у Москвы пока НЕТ ни одного: IMV/квартальный индекс/
|
||||
# sber_index покрывают регион 66. Пустое множество здесь — не заглушка,
|
||||
# а ФАКТ, который потребители обязаны озвучивать (см. класс-докстринг).
|
||||
enrichment_tiers=frozenset(),
|
||||
),
|
||||
}
|
||||
|
||||
DEFAULT_REGION_CODE = 66
|
||||
|
||||
|
||||
def region_for_point(lat: float, lon: float) -> Region | None:
|
||||
"""Регион покрытия, которому принадлежит точка (по bbox_region), или None.
|
||||
|
||||
Регионы географически не пересекаются; порядок обхода детерминирован кодом
|
||||
региона на случай, если когда-нибудь пересекутся (первый по коду выигрывает
|
||||
— и это станет видно в тестах реестра, а не в проде).
|
||||
"""
|
||||
for code in sorted(REGIONS):
|
||||
if is_within_bbox(lat, lon, REGIONS[code].bbox_region):
|
||||
return REGIONS[code]
|
||||
return None
|
||||
|
||||
|
||||
def region_by_city(city: str | None) -> Region | None:
|
||||
"""Регион, в чьём списке городов есть `city` (нормализованный нижний
|
||||
регистр, е/ё не различаются). None — город не узнан ни одним регионом."""
|
||||
if not city:
|
||||
return None
|
||||
token = " ".join(city.lower().replace("ё", "е").split())
|
||||
for code in sorted(REGIONS):
|
||||
r = REGIONS[code]
|
||||
if token in r.cities or any(token == c.replace("ё", "е") for c in r.cities):
|
||||
return r
|
||||
return None
|
||||
|
||||
|
||||
def unsupported_tier_reason(region: Region, tier: str) -> str | None:
|
||||
"""None — тир доступен региону; иначе готовая строка для лога/маркера.
|
||||
|
||||
Смысл — «регион без тира деградирует явно»: вызывающий код обязан
|
||||
залогировать/пометить причину, а не молча посчитать без источника.
|
||||
"""
|
||||
if tier in region.enrichment_tiers:
|
||||
return None
|
||||
return (
|
||||
f"тир '{tier}' недоступен региону {region.code} ({region.name}) — "
|
||||
f"оценка идёт без него; доступные тиры: "
|
||||
f"{sorted(region.enrichment_tiers) or 'нет ни одного'}"
|
||||
)
|
||||
|
|
@ -3,7 +3,8 @@
|
|||
No live Postgres needed — DB is a minimal fake returning queued results (mirrors the
|
||||
convention in tests/tasks/test_cadastral_geo_match.py / the deleted test_location_coef.py).
|
||||
Covers:
|
||||
- pure functions: _category_weight, _in_ekb_bbox, _pct_deviation (incl. monotonicity)
|
||||
- pure functions: _category_weight, _covered_region (реестр #3051), _pct_deviation
|
||||
(incl. monotonicity)
|
||||
- _fetch_nearby_poi: qualitative POI ranking (unchanged behaviour from the old module)
|
||||
- compute_location_index: out-of-coverage degradation, insufficient-sample degradation
|
||||
(citywide AND local), radius-ladder expansion, explicit radius_m override, happy path
|
||||
|
|
@ -48,22 +49,25 @@ def test_category_weight_unknown_and_none_fall_back_to_default() -> None:
|
|||
|
||||
|
||||
def test_in_ekb_bbox_center_is_inside() -> None:
|
||||
assert lc._in_ekb_bbox(_LAT_IN_EKB, _LON_IN_EKB) is True
|
||||
# #3051: _in_ekb_bbox → _covered_region (реестр регионов); граничные точки
|
||||
# ниже — те же, что до реестра: продукт-ядро 66 сохранено байт-в-байт.
|
||||
region = lc._covered_region(_LAT_IN_EKB, _LON_IN_EKB)
|
||||
assert region is not None and region.code == 66
|
||||
|
||||
|
||||
def test_in_ekb_bbox_bounds_are_inclusive() -> None:
|
||||
assert lc._in_ekb_bbox(56.70, 60.50) is True
|
||||
assert lc._in_ekb_bbox(56.95, 60.75) is True
|
||||
assert lc._covered_region(56.70, 60.50) is not None
|
||||
assert lc._covered_region(56.95, 60.75) is not None
|
||||
|
||||
|
||||
def test_in_ekb_bbox_outside_is_rejected() -> None:
|
||||
# Nizhny Tagil — same oblast (region_code=66), well outside the EKB product bbox.
|
||||
assert lc._in_ekb_bbox(57.910, 59.970) is False
|
||||
assert lc._covered_region(57.910, 59.970) is None
|
||||
# Just past each edge of the bbox.
|
||||
assert lc._in_ekb_bbox(56.69, 60.60) is False
|
||||
assert lc._in_ekb_bbox(56.96, 60.60) is False
|
||||
assert lc._in_ekb_bbox(56.80, 60.49) is False
|
||||
assert lc._in_ekb_bbox(56.80, 60.76) is False
|
||||
assert lc._covered_region(56.69, 60.60) is None
|
||||
assert lc._covered_region(56.96, 60.60) is None
|
||||
assert lc._covered_region(56.80, 60.49) is None
|
||||
assert lc._covered_region(56.80, 60.76) is None
|
||||
|
||||
|
||||
def test_pct_deviation_above_and_below_city_median() -> None:
|
||||
|
|
|
|||
136
tradein-mvp/backend/tests/test_3051_region_registry.py
Normal file
136
tradein-mvp/backend/tests/test_3051_region_registry.py
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
"""#3051 часть 1, шард 1a: реестр регионов — единственный источник гео-границ.
|
||||
|
||||
До реестра границы покрытия лежали литералами в трёх файлах (location_index /
|
||||
geocoder / matching.normalize), и каждая молча отвергла бы Москву. Теперь
|
||||
литералы живут ТОЛЬКО в app.services.regions; потребители держат прежние имена
|
||||
как алиасы на объекты реестра (identity, не копии — см. test_no_local_copies).
|
||||
|
||||
Приёмка задачи «московская координата не out_of_coverage» — красная на
|
||||
origin/main ПО ЗНАЧЕНИЮ: там compute_location_index возвращает out_of_coverage,
|
||||
с реестром — insufficient_data (регион узнан, данных Москвы пока ноль — честно).
|
||||
Поведение региона 66 закреплено байт-в-байт (bbox'ы равны прежним литералам).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from app.services import geocoder, location_index, regions
|
||||
from app.services.matching import normalize
|
||||
|
||||
# ── 1. Инварианты реестра ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _contains(outer: regions.BBox, inner: regions.BBox) -> bool:
|
||||
return (
|
||||
outer[0] <= inner[0]
|
||||
and outer[1] >= inner[1]
|
||||
and outer[2] <= inner[2]
|
||||
and outer[3] >= inner[3]
|
||||
)
|
||||
|
||||
|
||||
def test_bbox_nesting_invariants() -> None:
|
||||
"""region ⊇ wide ⊇ tight и region ⊇ product_core — у КАЖДОГО региона."""
|
||||
for r in regions.REGIONS.values():
|
||||
assert _contains(r.bbox_wide, r.bbox_tight), r.code
|
||||
assert _contains(r.bbox_region, r.bbox_wide), r.code
|
||||
assert _contains(r.bbox_region, r.bbox_product_core), r.code
|
||||
|
||||
|
||||
def test_regions_do_not_overlap() -> None:
|
||||
"""Центры продукт-ядер каждого региона не попадают в чужой bbox_region."""
|
||||
for r in regions.REGIONS.values():
|
||||
core = r.bbox_product_core
|
||||
center = ((core[0] + core[1]) / 2, (core[2] + core[3]) / 2)
|
||||
for other in regions.REGIONS.values():
|
||||
if other.code != r.code:
|
||||
assert not regions.is_within_bbox(*center, other.bbox_region), (
|
||||
f"центр {r.code} внутри bbox_region {other.code}"
|
||||
)
|
||||
|
||||
|
||||
# ── 2. Поведение региона 66 закреплено байт-в-байт ───────────────────────────
|
||||
|
||||
|
||||
def test_region66_values_are_the_historical_literals() -> None:
|
||||
"""Смена значений 66 = смена поведения гео-трактов — отдельное решение,
|
||||
этот PR только ПЕРЕНОСИТ литералы. Тест не даст «подкрутить по пути»."""
|
||||
r66 = regions.REGIONS[66]
|
||||
assert r66.bbox_tight == (56.65, 56.95, 60.40, 60.85)
|
||||
assert r66.bbox_wide == (56.6, 57.1, 60.3, 60.9)
|
||||
assert r66.bbox_region == (55.8, 62.2, 56.7, 66.6)
|
||||
assert r66.bbox_product_core == (56.70, 56.95, 60.50, 60.75)
|
||||
assert r66.city_token == "екатеринбург"
|
||||
|
||||
|
||||
def test_no_local_copies_in_consumers() -> None:
|
||||
"""Потребители держат ИМЕННО объекты реестра (identity), не копии —
|
||||
копия разъехалась бы при следующей правке (ровно так жила дыра #2656)."""
|
||||
r66 = regions.REGIONS[66]
|
||||
assert geocoder.EKB_BBOX_TIGHT is r66.bbox_tight
|
||||
assert geocoder.EKB_BBOX_WIDE is r66.bbox_wide
|
||||
assert geocoder.OBLAST66_BBOX is r66.bbox_region
|
||||
assert geocoder.SVERDLOVSK_OBLAST_CITIES is r66.cities
|
||||
assert normalize.EKB_CITY_TOKEN is r66.city_token
|
||||
|
||||
|
||||
# ── 3. Приёмка: Москва больше не out_of_coverage ─────────────────────────────
|
||||
|
||||
|
||||
def _empty_db() -> MagicMock:
|
||||
"""db, у которой любой SELECT пуст (mappings().first() → None)."""
|
||||
db = MagicMock()
|
||||
db.execute.return_value.mappings.return_value.first.return_value = None
|
||||
db.execute.return_value.mappings.return_value.all.return_value = []
|
||||
return db
|
||||
|
||||
|
||||
def test_moscow_point_is_covered_not_rejected() -> None:
|
||||
"""55.75/37.62 (центр Москвы): регион узнан → считаем; данных нет →
|
||||
insufficient_data. На origin/main здесь out_of_coverage (красный по значению)."""
|
||||
res = location_index.compute_location_index(_empty_db(), 55.75, 37.62)
|
||||
assert res.status == "insufficient_data", (
|
||||
f"Москва отвергнута статусом {res.status!r} — реестр не подключён"
|
||||
)
|
||||
|
||||
|
||||
def test_ekb_point_behaviour_unchanged() -> None:
|
||||
"""ЕКБ-центр: та же пустая db → insufficient_data (инвариант обеих эр)."""
|
||||
res = location_index.compute_location_index(_empty_db(), 56.8300, 60.6000)
|
||||
assert res.status == "insufficient_data"
|
||||
|
||||
|
||||
def test_point_outside_all_cores_still_rejected() -> None:
|
||||
"""Точка в tight-66, но ЮЖНЕЕ продукт-ядра (56.68 < 56.70) → по-прежнему
|
||||
out_of_coverage: ядро 66 сохранено байт-в-байт, покрытие НЕ расширилось
|
||||
молча (инвариант обеих эр). И Пермь — вне всего (инвариант)."""
|
||||
res = location_index.compute_location_index(_empty_db(), 56.68, 60.60)
|
||||
assert res.status == "out_of_coverage"
|
||||
res2 = location_index.compute_location_index(_empty_db(), 58.01, 56.25)
|
||||
assert res2.status == "out_of_coverage"
|
||||
|
||||
|
||||
# ── 4. Города и тиры ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_region_by_city_folds_and_resolves() -> None:
|
||||
assert regions.region_by_city("Екатеринбург").code == 66
|
||||
assert regions.region_by_city("Москва").code == 77
|
||||
assert regions.region_by_city("Берёзовский").code == 66 # ё-фолд
|
||||
assert regions.region_by_city("березовский").code == 66
|
||||
assert regions.region_by_city("Пермь") is None
|
||||
assert regions.region_by_city(None) is None
|
||||
|
||||
|
||||
def test_unsupported_tier_degrades_loudly_not_silently() -> None:
|
||||
"""У Москвы нет ни одного тира обогащения — и это ЯВНЫЙ факт с готовой
|
||||
формулировкой, а не молчаливое «посчитаем без источника»."""
|
||||
r77, r66 = regions.REGIONS[77], regions.REGIONS[66]
|
||||
assert regions.unsupported_tier_reason(r66, regions.TIER_AVITO_IMV) is None
|
||||
reason = regions.unsupported_tier_reason(r77, regions.TIER_AVITO_IMV)
|
||||
assert reason is not None and "77" in reason and "avito_imv" in reason
|
||||
Loading…
Add table
Reference in a new issue