Локальный индекс считает медианы по своему региону, а статус точек интереса не врёт (#3517)
All checks were successful
Deploy Trade-In / changes (push) Successful in 14s
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 3m46s
Deploy Trade-In / build-backend (push) Successful in 1m8s
Deploy Trade-In / deploy (push) Successful in 1m47s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 1m42s
All checks were successful
Deploy Trade-In / changes (push) Successful in 14s
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 3m46s
Deploy Trade-In / build-backend (push) Successful in 1m8s
Deploy Trade-In / deploy (push) Successful in 1m47s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 1m42s
This commit is contained in:
parent
66a8c39ee4
commit
76a2963edc
2 changed files with 187 additions and 16 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue