From 76a2963edc27cfba574da10b75af05ee71bc9fc7 Mon Sep 17 00:00:00 2001 From: lekss361 Date: Sun, 13 Sep 2026 11:14:44 +0000 Subject: [PATCH] =?UTF-8?q?=D0=9B=D0=BE=D0=BA=D0=B0=D0=BB=D1=8C=D0=BD?= =?UTF-8?q?=D1=8B=D0=B9=20=D0=B8=D0=BD=D0=B4=D0=B5=D0=BA=D1=81=20=D1=81?= =?UTF-8?q?=D1=87=D0=B8=D1=82=D0=B0=D0=B5=D1=82=20=D0=BC=D0=B5=D0=B4=D0=B8?= =?UTF-8?q?=D0=B0=D0=BD=D1=8B=20=D0=BF=D0=BE=20=D1=81=D0=B2=D0=BE=D0=B5?= =?UTF-8?q?=D0=BC=D1=83=20=D1=80=D0=B5=D0=B3=D0=B8=D0=BE=D0=BD=D1=83,=20?= =?UTF-8?q?=D0=B0=20=D1=81=D1=82=D0=B0=D1=82=D1=83=D1=81=20=D1=82=D0=BE?= =?UTF-8?q?=D1=87=D0=B5=D0=BA=20=D0=B8=D0=BD=D1=82=D0=B5=D1=80=D0=B5=D1=81?= =?UTF-8?q?=D0=B0=20=D0=BD=D0=B5=20=D0=B2=D1=80=D1=91=D1=82=20(#3517)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/app/services/location_index.py | 78 +++++++++-- .../tests/services/test_location_index.py | 125 +++++++++++++++++- 2 files changed, 187 insertions(+), 16 deletions(-) diff --git a/tradein-mvp/backend/app/services/location_index.py b/tradein-mvp/backend/app/services/location_index.py index 6b6c9c7e..b825b421 100644 --- a/tradein-mvp/backend/app/services/location_index.py +++ b/tradein-mvp/backend/app/services/location_index.py @@ -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: diff --git a/tradein-mvp/backend/tests/services/test_location_index.py b/tradein-mvp/backend/tests/services/test_location_index.py index 6a720c27..e0e23ba3 100644 --- a/tradein-mvp/backend/tests/services/test_location_index.py +++ b/tradein-mvp/backend/tests/services/test_location_index.py @@ -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