Локальный индекс считает медианы по своему региону, а статус точек интереса не врёт #3517

Merged
lekss361 merged 2 commits from fix/msk-location-index-region into main 2026-09-13 11:14:45 +00:00
2 changed files with 187 additions and 16 deletions

View file

@ -142,7 +142,8 @@ class LocationIndexResult(BaseModel):
sample_size: int
radius_m: int
nearby_poi: list[NearbyPoi]
poi_status: str # "ok" | "unavailable" (osm_poi_ekb_local пуста/не отрефрешена)
poi_status: str # "ok" | "unavailable" (osm_poi_ekb_local пуста/не отрефрешена
# ДЛЯ ПРОДУКТ-ЯДРА РЕГИОНА точки — не глобальный count(*) по таблице)
def _pct_deviation(local_median_ppm2: float, city_median_ppm2: float) -> float:
@ -207,9 +208,27 @@ def _pct_deviation(local_median_ppm2: float, city_median_ppm2: float) -> float:
# упирается в отказ редко — прод-симуляция на 246 реальных точках оценок:
# insufficient_data 0 → 1 точка (0.4%), 800м хватает 241 точке из 246.
#
# bbox-фильтр (lat/lon) — сопоставимые листинги считаются ТОЛЬКО по Екатеринбургу, даже если
# сам продукт уже скрейпит соседние города области (city-sweep): географию location_index
# явно ограничил владелец продукта.
# bbox-фильтр (lat/lon) — сопоставимые листинги считаются ТОЛЬКО в продукт-ядре региона,
# даже если сам продукт уже скрейпит соседние города/области (city-sweep): географию
# location_index явно ограничил владелец продукта.
#
# region_code (дефект Москвы, прод-замер 13.09.2026): bbox продукт-ядра — геометрический
# прямоугольник, а границы регионов НЕ прямоугольны — в московское ядро (55.55..55.95 /
# 37.30..37.90) попадают Химки/Реутов/Котельники/Люберцы (region_code=50). Внутри ядра 77
# лежало 16 801 лот региона 50 (медиана 251 295 ₽/м²) и 82 719 лотов региона 77 (медиана
# 458 208) — примесь заниженная городскую медиану до 422 222 (7.9%). У областных строк
# `city IS NULL`, фильтровать по имени города нельзя — только по region_code.
#
# СТРОГОЕ равенство, БЕЗ `OR region_code IS NULL`: строка без региона не считается ни за
# один регион. Второй прод-замер (13.09.2026, полный набор фильтров модуля — is_active,
# price sanity, geo_precision, свежесть 14 дней, сегмент, bbox ядра 66): 15 858 строк
# проходят фильтры, из них 3 с NULL region_code; медиана с ними 151 101 ₽/м², без них
# 151 103 — сдвиг 2 рубля на 151 тысяче (0.001%), статистически ничто для 66. Взамен —
# `region_code IS NULL` НЕ постоянная лазейка того же класса, что и сам дефект: в
# `listings` 16 930 NULL-region строк Avito, 24 из них лежат в bbox Москвы и сегодня не
# проходят по свежести — но могут завтра. Мягкий `OR NULL` тихо открыл бы им дорогу
# обратно в московскую медиану ровно тем же путём, каким туда попадала Московская
# область. Три рубля на 66 — цена закрытия дыры, а не повод её оставить.
_MEDIAN_PPM2_LOCAL_SQL = text(
"""
SELECT
@ -223,6 +242,7 @@ _MEDIAN_PPM2_LOCAL_SQL = text(
AND (geo_precision IS DISTINCT FROM 'city')
AND scraped_at > NOW() - (:fresh_days || ' days')::interval
AND (listing_segment IS NULL OR listing_segment = 'vtorichka')
AND region_code = CAST(:region_code AS integer)
AND lat BETWEEN CAST(:bbox_south AS double precision)
AND CAST(:bbox_north AS double precision)
AND lon BETWEEN CAST(:bbox_west AS double precision)
@ -248,6 +268,7 @@ _MEDIAN_PPM2_CITYWIDE_SQL = text(
AND (geo_precision IS DISTINCT FROM 'city')
AND scraped_at > NOW() - (:fresh_days || ' days')::interval
AND (listing_segment IS NULL OR listing_segment = 'vtorichka')
AND region_code = CAST(:region_code AS integer)
AND lat BETWEEN CAST(:bbox_south AS double precision)
AND CAST(:bbox_north AS double precision)
AND lon BETWEEN CAST(:bbox_west AS double precision)
@ -255,6 +276,17 @@ _MEDIAN_PPM2_CITYWIDE_SQL = text(
"""
)
_POI_COUNT_IN_REGION_SQL = text(
"""
SELECT count(*) AS n
FROM osm_poi_ekb_local
WHERE lat BETWEEN CAST(:bbox_south AS double precision)
AND CAST(:bbox_north AS double precision)
AND lon BETWEEN CAST(:bbox_west AS double precision)
AND CAST(:bbox_east AS double precision)
"""
)
_NEAREST_POI_SQL = text(
"""
SELECT
@ -293,6 +325,7 @@ def _local_median_ppm2(
"fresh_days": LISTINGS_FRESH_DAYS,
"price_min": _PRICE_PER_M2_SANITY_MIN,
"price_max": _PRICE_PER_M2_SANITY_MAX,
"region_code": region.code,
"bbox_south": lat_min,
"bbox_north": lat_max,
"bbox_west": lon_min,
@ -317,6 +350,7 @@ def _citywide_median_ppm2(db: Any, region: regions_mod.Region) -> tuple[float |
"fresh_days": LISTINGS_FRESH_DAYS,
"price_min": _PRICE_PER_M2_SANITY_MIN,
"price_max": _PRICE_PER_M2_SANITY_MAX,
"region_code": region.code,
"bbox_south": lat_min,
"bbox_north": lat_max,
"bbox_west": lon_min,
@ -333,18 +367,38 @@ def _citywide_median_ppm2(db: Any, region: regions_mod.Region) -> tuple[float |
def _fetch_nearby_poi(
db: Any, lat: float, lon: float, radius_m: int, top_n: int
db: Any, lat: float, lon: float, radius_m: int, top_n: int, region: regions_mod.Region
) -> tuple[list[NearbyPoi], str]:
"""Top-N POI поблизости — качественная справка «что рядом», не числовой показатель.
Graceful fallback: osm_poi_ekb_local пуста (рефреш ещё не запускался на этом окружении)
([], "unavailable") вместо 500 или сфабрикованного списка.
Graceful fallback ([], "unavailable") вместо 500 или сфабрикованного списка. Дефект
(прод 13.09.2026): гейт раньше смотрел на count(*) ПО ВСЕЙ osm_poi_ekb_local (4 850
строк, все екатеринбургские) для Москвы это count > 0 (таблица не пуста), и статус
молча становился "ok" с пустым списком, хотя внутри московского продукт-ядра лежит 0
POI. Гейт обязан смотреть на наличие POI В РЕЛЕВАНТНОЙ области (bbox_product_core
региона точки), а не на размер таблицы целиком. Загрузка московских POI отдельная
задача; здесь только честная деградация тем же статусом "unavailable" (терминология
не расширяется trade_in.py и так трактует его как «нет данных», см. #2131).
"""
total = db.execute(text("SELECT count(*) FROM osm_poi_ekb_local")).scalar() or 0
if total == 0:
lat_min, lat_max, lon_min, lon_max = region.bbox_product_core
in_region = (
db.execute(
_POI_COUNT_IN_REGION_SQL,
{
"bbox_south": lat_min,
"bbox_north": lat_max,
"bbox_west": lon_min,
"bbox_east": lon_max,
},
).scalar()
or 0
)
if in_region == 0:
logger.warning(
"location_index: osm_poi_ekb_local is empty (refresh job not yet run on this "
"environment) — nearby_poi unavailable, no fabricated factors"
"location_index: osm_poi_ekb_local has no rows in region=%s product core "
"(empty or refresh job not yet run for this region) — nearby_poi unavailable, "
"no fabricated factors",
region.code,
)
return [], "unavailable"
@ -423,7 +477,7 @@ def compute_location_index(
poi_status="unavailable",
)
nearby_poi, poi_status = _fetch_nearby_poi(db, lat, lon, poi_radius_m, poi_top_n)
nearby_poi, poi_status = _fetch_nearby_poi(db, lat, lon, poi_radius_m, poi_top_n, region)
city_median, city_n = _citywide_median_ppm2(db, region)
if city_median is None or city_n < MIN_SAMPLE_SIZE:

View file

@ -25,6 +25,12 @@ from app.services import location_index as lc
# A point well inside the EKB coverage bbox (city centre, Ploshchad 1905 goda area).
_LAT_IN_EKB = 56.838
_LON_IN_EKB = 60.605
_REGION_66 = lc.regions_mod.REGIONS[66]
# A point well inside Moscow's product core (within MKAD) — #3051 region 77.
_LAT_IN_MSK = 55.75
_LON_IN_MSK = 37.62
_REGION_77 = lc.regions_mod.REGIONS[77]
# ── Pure functions ──────────────────────────────────────────────────────────
@ -118,6 +124,7 @@ def test_no_psycopg_v3_colon_colon_cast() -> None:
lc._MEDIAN_PPM2_LOCAL_SQL,
lc._MEDIAN_PPM2_CITYWIDE_SQL,
lc._NEAREST_POI_SQL,
lc._POI_COUNT_IN_REGION_SQL,
):
assert not re.search(r":\w+::", str(sql.text))
@ -142,6 +149,51 @@ def test_median_queries_exclude_city_centroid_and_bound_bbox() -> None:
assert "bbox_west" in sql_text and "bbox_east" in sql_text
# ── #msk-defect-1: медианы обязаны фильтроваться по региону, не только по bbox ──
def test_median_queries_filter_by_region_code() -> None:
"""Прод-замер 13.09.2026: bbox продукт-ядра Москвы геометрически захватывает соседние
region_code=50 (Химки/Реутов/Котельники/Люберцы) без фильтра по региону городская
медиана Москвы считалась с примесью 16 801 подмосковного лота и была занижена на 7.9%
(422 222 вместо 458 208 /м²). У областных строк city IS NULL фильтровать можно
только по region_code, не по имени города."""
for sql in (lc._MEDIAN_PPM2_LOCAL_SQL, lc._MEDIAN_PPM2_CITYWIDE_SQL):
sql_text = str(sql.text)
assert "region_code = CAST(:region_code AS integer)" in sql_text
def test_median_queries_do_not_admit_null_region_code() -> None:
"""Строгое равенство, БЕЗ `OR region_code IS NULL` — строка без региона не считается
ни за один регион. Второй прод-замер (13.09.2026, полный набор фильтров модуля, bbox
ядра 66): 15 858 строк проходят фильтры, 3 из них с NULL region_code; медиана с ними
151 101 /м², без них 151 103 сдвиг 2 рубля на 151 тысяче (0.001%), для 66
статистически ничто. Мягкий `OR NULL` был бы лазейкой того же класса, что и сам
дефект: в `listings` 16 930 NULL-region строк Avito, 24 из них уже лежат в bbox
Москвы (сегодня не проходят по свежести, но могут завтра) `OR NULL` тихо открыл бы
им дорогу обратно в московскую медиану."""
for sql in (lc._MEDIAN_PPM2_LOCAL_SQL, lc._MEDIAN_PPM2_CITYWIDE_SQL):
sql_text = str(sql.text)
assert "region_code IS NULL" not in sql_text
assert "OR region_code" not in sql_text
def test_local_and_citywide_median_bind_region_code_of_the_given_region() -> None:
"""region_code реально долетает bind-параметром и берётся ИЗ переданного региона, а не
захардкожен иначе фикс работал бы только для 66 и молча ломался на следующем регионе."""
db66 = _FakeDB([_FakeResult(mapping_one={"median_ppm2": 150_000.0, "n": 4000})])
lc._citywide_median_ppm2(db66, _REGION_66)
assert db66.executed[0][1]["region_code"] == 66
db77 = _FakeDB([_FakeResult(mapping_one={"median_ppm2": 458_208.0, "n": 82_719})])
lc._citywide_median_ppm2(db77, _REGION_77)
assert db77.executed[0][1]["region_code"] == 77
local66 = _FakeDB([_FakeResult(mapping_one={"median_ppm2": 165_000.0, "n": 25})])
lc._local_median_ppm2(local66, _LAT_IN_EKB, _LON_IN_EKB, 800, _REGION_66)
assert local66.executed[0][1]["region_code"] == 66
# ── #2660: свежесть + сегмент (зеркало _COMMON_WHERE эстиматора) ─────────────
@ -246,7 +298,9 @@ class _FakeDB:
def test_fetch_nearby_poi_empty_mirror_returns_unavailable() -> None:
db = _FakeDB([_FakeResult(scalar_value=0)])
poi, status = lc._fetch_nearby_poi(db, _LAT_IN_EKB, _LON_IN_EKB, lc.DEFAULT_POI_RADIUS_M, 7)
poi, status = lc._fetch_nearby_poi(
db, _LAT_IN_EKB, _LON_IN_EKB, lc.DEFAULT_POI_RADIUS_M, 7, _REGION_66
)
assert poi == []
assert status == "unavailable"
assert len(db.executed) == 1 # only the count probe ran
@ -254,7 +308,9 @@ def test_fetch_nearby_poi_empty_mirror_returns_unavailable() -> None:
def test_fetch_nearby_poi_no_poi_in_radius_is_legit_ok() -> None:
db = _FakeDB([_FakeResult(scalar_value=500), _FakeResult(mapping_rows=[])])
poi, status = lc._fetch_nearby_poi(db, _LAT_IN_EKB, _LON_IN_EKB, lc.DEFAULT_POI_RADIUS_M, 7)
poi, status = lc._fetch_nearby_poi(
db, _LAT_IN_EKB, _LON_IN_EKB, lc.DEFAULT_POI_RADIUS_M, 7, _REGION_66
)
assert poi == []
assert status == "ok"
@ -267,7 +323,7 @@ def test_fetch_nearby_poi_ranks_by_weight_not_distance_only() -> None:
{"name": "Аптека", "category": "pharmacy", "distance_m": 50.0},
]
db = _FakeDB([_FakeResult(scalar_value=1000), _FakeResult(mapping_rows=rows)])
poi, status = lc._fetch_nearby_poi(db, _LAT_IN_EKB, _LON_IN_EKB, 1200, 7)
poi, status = lc._fetch_nearby_poi(db, _LAT_IN_EKB, _LON_IN_EKB, 1200, 7, _REGION_66)
assert status == "ok"
assert len(poi) == 4
# metro_stop (weight 6.0) at 150m outranks school (5.0) at 300m — weight-driven, not
@ -281,7 +337,7 @@ def test_fetch_nearby_poi_limits_to_top_n() -> None:
for i in range(20)
]
db = _FakeDB([_FakeResult(scalar_value=20), _FakeResult(mapping_rows=rows)])
poi, _status = lc._fetch_nearby_poi(db, _LAT_IN_EKB, _LON_IN_EKB, 1200, 7)
poi, _status = lc._fetch_nearby_poi(db, _LAT_IN_EKB, _LON_IN_EKB, 1200, 7, _REGION_66)
assert len(poi) == 7
@ -407,3 +463,64 @@ def test_compute_location_index_poi_unavailable_does_not_block_index() -> None:
assert result.poi_status == "unavailable"
assert result.nearby_poi == []
assert result.location_index_pct == 15.0
# ── #msk-defect-1/2: Москва (region 77) — не течёт в EKB, честная POI-деградация ──
def test_covered_region_resolves_moscow_center_to_region_77() -> None:
"""МКАД-центр Москвы должен попадать в продукт-ядро 77, а не в 50 (см. комментарий у
regions._POINT_LOOKUP_ORDER компактный bbox_region проверяется раньше объемлющего)."""
region = lc._covered_region(_LAT_IN_MSK, _LON_IN_MSK)
assert region is not None and region.code == 77
def test_fetch_nearby_poi_gate_uses_regions_own_bbox_not_whole_table() -> None:
"""Дефект (прод 13.09.2026): гейт раньше делал `count(*)` по ВСЕЙ osm_poi_ekb_local
(4 850 екатеринбургских строк, всегда > 0) Москва получала бы poi_status="ok" с
пустым списком. Теперь count скоупится в bbox_product_core региона точки: для 77
внутри его ядра POI нет (0), для 66 те же 4 850 строк, что и раньше."""
db_msk = _FakeDB([_FakeResult(scalar_value=0)])
poi, status = lc._fetch_nearby_poi(
db_msk, _LAT_IN_MSK, _LON_IN_MSK, lc.DEFAULT_POI_RADIUS_M, 7, _REGION_77
)
assert poi == []
assert status == "unavailable" # честная деградация, ТА ЖЕ терминология, что и раньше
# bbox в запросе — Москвы, не Екатеринбурга (это и есть суть фикса).
poi_count_params = db_msk.executed[0][1]
lat_min, lat_max, lon_min, lon_max = _REGION_77.bbox_product_core
assert poi_count_params["bbox_south"] == lat_min
assert poi_count_params["bbox_north"] == lat_max
assert poi_count_params["bbox_west"] == lon_min
assert poi_count_params["bbox_east"] == lon_max
def test_fetch_nearby_poi_gate_still_scopes_to_ekb_bbox_for_region_66() -> None:
"""Regression: для 66 гейт обязан продолжать спрашивать про bbox ЕКБ — byte-for-byte,
просто теперь явно скоупенный, а не «случайно совпадающий» с count(*) по всей таблице."""
db = _FakeDB([_FakeResult(scalar_value=4850), _FakeResult(mapping_rows=[])])
lc._fetch_nearby_poi(db, _LAT_IN_EKB, _LON_IN_EKB, lc.DEFAULT_POI_RADIUS_M, 7, _REGION_66)
poi_count_params = db.executed[0][1]
lat_min, lat_max, lon_min, lon_max = _REGION_66.bbox_product_core
assert poi_count_params["bbox_south"] == lat_min
assert poi_count_params["bbox_north"] == lat_max
assert poi_count_params["bbox_west"] == lon_min
assert poi_count_params["bbox_east"] == lon_max
def test_compute_location_index_moscow_point_isolates_region_and_degrades_honestly() -> None:
"""End-to-end #3051 Москва: citywide/local запросы обязаны нести region_code=77 (не 66,
не отсутствие фильтра), а poi_status обязан быть честным "unavailable" НЕ "ok" с
пустым списком, как было до фикса дефекта 2."""
db = _FakeDB(
[
_FakeResult(scalar_value=0), # poi-count in Moscow core: 0
_FakeResult(mapping_one={"median_ppm2": None, "n": 0}), # citywide: no MSK data yet
]
)
result = lc.compute_location_index(db, lat=_LAT_IN_MSK, lon=_LON_IN_MSK)
assert result.status == "insufficient_data" # честно: листингов Москвы пока нет
assert result.poi_status == "unavailable"
assert result.nearby_poi == []
citywide_params = db.executed[1][1]
assert citywide_params["region_code"] == 77