diff --git a/backend/app/services/site_finder/poi_loader.py b/backend/app/services/site_finder/poi_loader.py index 709943ec..d0473ad8 100644 --- a/backend/app/services/site_finder/poi_loader.py +++ b/backend/app/services/site_finder/poi_loader.py @@ -19,60 +19,110 @@ logger = logging.getLogger(__name__) OVERPASS_URL = "https://overpass-api.de/api/interpreter" EKB_BBOX = (56.7, 60.5, 56.95, 60.75) # (south, west, north, east) -# Маппинг OSM-тег → нормализованная category -OSM_CATEGORIES: dict[tuple[str, str], str] = { +# Маппинг набора OSM-тегов (все теги в кортеже должны совпасть — AND) → нормализованная +# category. Каждая запись — один per-category Overpass-запрос (см. _build_overpass_query); +# несколько записей с ОДИНАКОВЫМ значением category (как у metro_stop ниже) — это "ИЛИ" на +# уровне отдельных HTTP-запросов: элемент, подходящий под любую из альтернативных схем +# разметки, попадёт в категорию. +OSM_CATEGORIES: dict[tuple[tuple[str, str], ...], str] = { # amenity tags — школы расширены (school/college/university) - ("amenity", "school"): "school", - ("amenity", "college"): "school", - ("amenity", "university"): "school", - ("amenity", "kindergarten"): "kindergarten", - ("amenity", "pharmacy"): "pharmacy", - ("amenity", "hospital"): "hospital", - ("amenity", "clinic"): "hospital", + (("amenity", "school"),): "school", + (("amenity", "college"),): "school", + (("amenity", "university"),): "school", + (("amenity", "kindergarten"),): "kindergarten", + (("amenity", "pharmacy"),): "pharmacy", + (("amenity", "hospital"),): "hospital", + (("amenity", "clinic"),): "hospital", # shop tags — supermarket расширен - ("shop", "mall"): "shop_mall", - ("shop", "supermarket"): "shop_supermarket", - ("shop", "hypermarket"): "shop_supermarket", - ("shop", "convenience"): "shop_small", - ("shop", "bakery"): "shop_small", + (("shop", "mall"),): "shop_mall", + (("shop", "supermarket"),): "shop_supermarket", + (("shop", "hypermarket"),): "shop_supermarket", + (("shop", "convenience"),): "shop_small", + (("shop", "bakery"),): "shop_small", # leisure - ("leisure", "park"): "park", + (("leisure", "park"),): "park", # transit - ("railway", "tram_stop"): "tram_stop", - ("highway", "bus_stop"): "bus_stop", - # метро (одна линия в ЕКБ, но добавляем для полноты) - ("station", "subway"): "metro_stop", + (("railway", "tram_stop"),): "tram_stop", + (("highway", "bus_stop"),): "bus_stop", + # Метро ЕКБ (9 станций, одна линия). Fix (location-index rework): фильтр раньше ловил + # ТОЛЬКО station=subway и подтягивал лишь 5/9 станций — часть станций в OSM размечена + # без ключа "station" вовсе, комбинацией railway=station + subway=yes (альтернативная, + # но распространённая схема разметки метро). Обе схемы — отдельными записями ниже, чтобы + # не терять станции, размеченные любой из них. + (("station", "subway"),): "metro_stop", + (("railway", "station"), ("subway", "yes")): "metro_stop", } -def _build_overpass_query_single(key: str, value: str) -> str: - """Запрос для одной пары tag → нормированной категории. +def _build_overpass_query(tag_filters: tuple[tuple[str, str], ...]) -> str: + """Запрос для ОДНОЙ комбинации tag=value (обычно один тег, иногда несколько — все AND). Раньше делали один большой запрос на все 14 категорий — Overpass возвращал 504 Gateway Timeout (запрос слишком тяжёлый). Сплит на per-category даёт - 14 быстрых запросов вместо одного 60+ секундного. + быстрые запросы вместо одного 60+ секундного. """ south, west, north, east = EKB_BBOX bbox = f"({south},{west},{north},{east})" - return ( - f"[out:json][timeout:30];" - f'(node["{key}"="{value}"]{bbox};way["{key}"="{value}"]{bbox};);' - f"out center meta;" - ) + filt = "".join(f'["{k}"="{v}"]' for k, v in tag_filters) + return f"[out:json][timeout:30];(node{filt}{bbox};way{filt}{bbox};);out center meta;" def _classify(tags: dict[str, str]) -> str | None: """Определить category из OSM-тегов. None если не соответствует ни одной.""" - for (k, v), cat in OSM_CATEGORIES.items(): - if tags.get(k) == v: + for tag_filters, cat in OSM_CATEGORIES.items(): + if all(tags.get(k) == v for k, v in tag_filters): return cat return None +def _tag_filters_desc(tag_filters: tuple[tuple[str, str], ...]) -> str: + return ",".join(f"{k}={v}" for k, v in tag_filters) + + +async def _fetch_category( + client: httpx.AsyncClient, tag_filters: tuple[tuple[str, str], ...], category: str +) -> list[dict]: + """Один per-category Overpass-запрос с ОДНИМ повтором при транзиентной ошибке. + + Fix (location-index rework, "не потерялись крупные категории"): раньше единственная + неудача (таймаут / 504) на всю неделю обнуляла категорию целиком (следующая попытка — + только на следующем weekly run). Один retry с паузой снимает большую часть транзиентных + сбоев без риска зациклиться (Overpass rate-limit — max 2 concurrent, поэтому не более + 2 попыток на категорию). + """ + tag_desc = _tag_filters_desc(tag_filters) + query = _build_overpass_query(tag_filters) + for attempt in (1, 2): + try: + r = await client.post(OVERPASS_URL, data={"data": query}) + r.raise_for_status() + elements: list[dict] = r.json().get("elements", []) + logger.info( + "Overpass: %s (%s) → %d [attempt %d]", tag_desc, category, len(elements), attempt + ) + # Привязываем category именно к тому per-category запросу, под который + # элемент реально пришёл. Элемент с двумя целевыми тегами (например + # amenity=pharmacy + shop=supermarket) приходит дважды — каждая копия + # несёт свою category. Иначе _classify по dict-порядку молча терял бы + # вторую категорию при UPSERT по UNIQUE(osm_type, osm_id, category). См. #1372. + for el in elements: + el["_gd_category"] = category + return elements + except Exception as e: + if attempt == 1: + logger.warning("Overpass failed for %s (attempt 1, retrying): %s", tag_desc, e) + await asyncio.sleep(3.0) + continue + logger.warning( + "Overpass failed for %s after retry — category skipped this run: %s", tag_desc, e + ) + return [] + + async def fetch_overpass() -> list[dict]: """Запросить Overpass API per category, вернуть combined список elements. - Делаем 14 отдельных запросов вместо одного гигантского — большой запрос + Делаем отдельные запросы вместо одного гигантского — большой запрос отдаёт 504 Gateway Timeout. Между запросами sleep 1с (Overpass usage policy: max 2 concurrent, лучше 1 req/s). @@ -85,27 +135,14 @@ async def fetch_overpass() -> list[dict]: } all_elements: list[dict] = [] async with httpx.AsyncClient(timeout=60, headers=headers) as client: - for (key, value), category in OSM_CATEGORIES.items(): - query = _build_overpass_query_single(key, value) - try: - r = await client.post(OVERPASS_URL, data={"data": query}) - r.raise_for_status() - elements: list[dict] = r.json().get("elements", []) - logger.info("Overpass: %s=%s (%s) → %d", key, value, category, len(elements)) - # Привязываем category именно к тому per-category запросу, под который - # элемент реально пришёл. Элемент с двумя целевыми тегами (например - # amenity=pharmacy + shop=supermarket) приходит дважды — каждая копия - # несёт свою category. Иначе _classify по dict-порядку молча терял бы - # вторую категорию при UPSERT по UNIQUE(osm_type, osm_id, category). См. #1372. - for el in elements: - el["_gd_category"] = category - all_elements.extend(elements) - except Exception as e: - # Не падаем на одной категории — логируем и продолжаем - logger.warning("Overpass failed for %s=%s: %s", key, value, e) + for tag_filters, category in OSM_CATEGORIES.items(): + elements = await _fetch_category(client, tag_filters, category) + all_elements.extend(elements) await asyncio.sleep(1.0) logger.info( - "Overpass: total %d elements across %d categories", len(all_elements), len(OSM_CATEGORIES) + "Overpass: total %d elements across %d category-queries", + len(all_elements), + len(OSM_CATEGORIES), ) return all_elements diff --git a/data/sql/188_tradein_osm_poi_view_relax_freshness.sql b/data/sql/188_tradein_osm_poi_view_relax_freshness.sql new file mode 100644 index 00000000..725dda34 --- /dev/null +++ b/data/sql/188_tradein_osm_poi_view_relax_freshness.sql @@ -0,0 +1,48 @@ +-- 188_tradein_osm_poi_view_relax_freshness.sql +-- Fix for 185_tradein_osm_poi_view.sql: v_tradein_osm_poi_ekb enforced a HARD 2-year +-- OSM last-edit-date filter that silently dropped legitimate, stable infrastructure. +-- +-- CONTEXT (trade-in location-index rework, replaces the broken location-coef): +-- Audit of the trade-in POI mirror (osm_poi_ekb_local, fed by this view via the FDW +-- bridge) found only 5 of 9 EKB metro stations and just 2787 POI total reaching +-- tradein-mvp, despite osm_poi_ekb (this table, Site Finder's own registry) having more. +-- +-- Root cause: this view's WHERE clause dropped any POI whose OSM `last_osm_edit_date` is +-- older than 2 years. A subway station node, once correctly mapped, is essentially never +-- re-edited in OSM — "stale last edit" here means "nobody touched this tag in years", +-- NOT "this station stopped existing". The same logic applies to schools/hospitals/parks: +-- physically permanent infrastructure that simply isn't re-edited often. +-- +-- The "2-year freshness" requirement itself (see 82_osm_poi_ekb.sql, "требование +-- Максима") was intended as a SOFT confidence signal, not a hard existence filter — Site +-- Finder itself (backend/app/api/v1/parcels.py, "POI freshness" confidence subscore) only +-- uses last_osm_edit_date to DERATE a confidence score; every POI stays in the result set +-- regardless of staleness. This view diverged into a hard filter when the FDW bridge was +-- built (185) — this migration fixes that divergence, matching Site Finder's own intent. +-- +-- WHAT: CREATE OR REPLACE VIEW, same 4-column shape as 185 (category, name, lat, lon) — no +-- WHERE clause. tradein-mvp's FDW foreign table (tradein-mvp/backend/data/sql/ +-- 168_fdw_osm_poi_ekb.sql) is untouched — same column list, so no FDW-side change needed. +-- +-- Idempotent: CREATE OR REPLACE VIEW. GRANT re-applied (idempotent, matches 185). + +BEGIN; + +CREATE OR REPLACE VIEW v_tradein_osm_poi_ekb AS +SELECT + category, + name, + lat, + lon +FROM osm_poi_ekb; + +GRANT SELECT ON v_tradein_osm_poi_ekb TO tradein_fdw_reader; + +COMMENT ON VIEW v_tradein_osm_poi_ekb IS + 'FDW source for tradein-mvp (postgres_fdw) location-index/nearby-POI list (replaces the ' + 'broken location-coef, #2045). No freshness filter — last_osm_edit_date is a soft ' + 'confidence signal only (see Site Finder parcels.py), not evidence a POI stopped ' + 'existing. Fixes 185_tradein_osm_poi_view.sql hard 2-year WHERE filter that silently ' + 'dropped 4/9 EKB metro stations + other stable infrastructure from the trade-in mirror.'; + +COMMIT; diff --git a/tradein-mvp/backend/app/api/v1/trade_in.py b/tradein-mvp/backend/app/api/v1/trade_in.py index b28232aa..13f4213a 100644 --- a/tradein-mvp/backend/app/api/v1/trade_in.py +++ b/tradein-mvp/backend/app/api/v1/trade_in.py @@ -28,8 +28,8 @@ from app.schemas.trade_in import ( HouseAnalyticsResponse, HouseInfoForEstimate, IMVBenchmarkResponse, - LocationCoefFactorOut, - LocationCoefResponse, + LocationIndexResponse, + NearbyPoiOut, PhotoMeta, PlacementHistoryEntry, PriceHistoryYearPoint, @@ -1567,39 +1567,43 @@ def get_estimate_imv_benchmark( ) -# ── Location-coef POI scoring (#2045 BE-3, LocationDrawer) ──────────────────── +# ── Location index (issue TBD, замена сломанного location-coef #2045) ──────── -@router.get("/location-coef", response_model=LocationCoefResponse) -def get_location_coef( +@router.get("/location-index", response_model=LocationIndexResponse) +def get_location_index( estimate_id: UUID, db: Annotated[Session, Depends(get_db)], x_authenticated_user: Annotated[str | None, Header(alias="X-Authenticated-User")] = None, radius_m: int | None = None, -) -> LocationCoefResponse: - """Location-coefficient POI-скоринг для оценки (#2045 BE-3, LocationDrawer). +) -> LocationIndexResponse: + """Location index для оценки (замена сломанного location-coef, LocationDrawer). - Резолвит lat/lon/median_price оценки, считает coef через - app.services.location_coef.compute_location_coef — straight-line POI weighted score - (портировано из Site Finder poi_score.py) поверх локального зеркала osm_poi_ekb_local, - обновляемого scheduler'ом (source=osm_poi_ekb_refresh). result_price_rub = round( - base_price_rub * coef). + Резолвит lat/lon оценки, считает индекс через + app.services.location_index.compute_location_index: % отклонения медианы ₽/м² + сопоставимых активных листингов в радиусе точки от медианы ₽/м² по всему Екатеринбургу + (percentile_cont(0.5) — устойчиво к выбросам). НЕ участвует в цене — estimator.py про + этот показатель не знает (аналоги уже несут локацию в базовой цене). 404 — оценки нет / IDOR (тот же _assert_estimate_access_by_id, что и у соседних - derived-роутов). radius_m опционален (None → DEFAULT_RADIUS_M=1200м, подобран для - МКД, НЕ Ptica-дефолт 2000м для участков); явное значение клэмпится в [500, 3000]. + derived-роутов). radius_m опционален (None → адаптивная лестница радиусов + RADIUS_LADDER_M, расширяется пока выборка не наберёт MIN_SAMPLE_SIZE); явное значение + клэмпится в [500, 3000] и используется РОВНО как задано (без расширения). - Graceful fallback (НЕ 500, НЕ сфабрикованные факторы): - - osm_poi_ekb_local пуста/не отрефрешена на этом окружении → coef=1.0, factors=[], - geo_source="unavailable" (см. compute_location_coef). - - у оценки нет lat/lon (легаси/geo-fallback не сработал на POST) → тот же fallback. + Честная деградация (НЕ 500, НЕ сфабрикованные значения) — см. LocationIndexResponse: + - status="out_of_coverage" — у оценки нет lat/lon, ИЛИ точка вне гео-охвата продукта + (Екатеринбург). + - status="insufficient_data" — даже на максимальном радиусе сопоставимых активных + листингов меньше порога. + - poi_status="unavailable" — osm_poi_ekb_local пуста/не отрефрешена (независимо от + status выше — «что рядом» и числовой индекс деградируют раздельно). """ _assert_estimate_access_by_id(db, estimate_id, x_authenticated_user) row = db.execute( text( """ - SELECT lat, lon, median_price + SELECT lat, lon FROM trade_in_estimates WHERE id = CAST(:id AS uuid) """ @@ -1609,34 +1613,38 @@ def get_location_coef( if row is None: raise HTTPException(status_code=404, detail="estimate not found") - base_price_rub = int(row.median_price or 0) - if row.lat is None or row.lon is None: - logger.info("location_coef: estimate=%s has no lat/lon — unavailable fallback", estimate_id) - return LocationCoefResponse( - coef=1.0, - factors=[], - geo_source="unavailable", - base_price_rub=base_price_rub, - result_price_rub=base_price_rub, + logger.info( + "location_index: estimate=%s has no lat/lon — out_of_coverage fallback", estimate_id + ) + return LocationIndexResponse( + status="out_of_coverage", + location_index_pct=None, + local_median_price_per_m2=None, + city_median_price_per_m2=None, + sample_size=0, + radius_m=radius_m or 0, + nearby_poi=[], + poi_status="unavailable", ) - from app.services.location_coef import DEFAULT_RADIUS_M, compute_location_coef + from app.services.location_index import compute_location_index - resolved_radius = DEFAULT_RADIUS_M if radius_m is None else max(500, min(radius_m, 3000)) - result = compute_location_coef(db, float(row.lat), float(row.lon), radius_m=resolved_radius) + resolved_radius = None if radius_m is None else max(500, min(radius_m, 3000)) + result = compute_location_index(db, float(row.lat), float(row.lon), radius_m=resolved_radius) - return LocationCoefResponse( - coef=result.coef, - factors=[ - LocationCoefFactorOut( - poi_type=f.poi_type, name=f.name, distance_m=f.distance_m, weight=f.weight - ) - for f in result.factors + return LocationIndexResponse( + status=result.status, + location_index_pct=result.location_index_pct, + local_median_price_per_m2=result.local_median_price_per_m2, + city_median_price_per_m2=result.city_median_price_per_m2, + sample_size=result.sample_size, + radius_m=result.radius_m, + nearby_poi=[ + NearbyPoiOut(poi_type=p.poi_type, name=p.name, distance_m=p.distance_m) + for p in result.nearby_poi ], - geo_source=result.geo_source, - base_price_rub=base_price_rub, - result_price_rub=round(base_price_rub * result.coef), + poi_status=result.poi_status, ) diff --git a/tradein-mvp/backend/app/schemas/trade_in.py b/tradein-mvp/backend/app/schemas/trade_in.py index c47b8e39..69dee79d 100644 --- a/tradein-mvp/backend/app/schemas/trade_in.py +++ b/tradein-mvp/backend/app/schemas/trade_in.py @@ -598,29 +598,48 @@ class QuotaStatus(BaseModel): unlimited: bool # True для admin / kopylov / без заголовка -class LocationCoefFactorOut(BaseModel): - """Один POI-фактор в ответе GET /api/v1/trade-in/location-coef (#2045 BE-3).""" +class NearbyPoiOut(BaseModel): + """Один пункт «что рядом» в ответе GET /api/v1/trade-in/location-index. + + Качественная справка (школа 185 м, остановка 93 м) — НЕ участвует в location_index_pct. + """ poi_type: str # категория POI (school/kindergarten/metro_stop/... — те же значения, # что в osm_poi_ekb на стороне gendesign) name: str | None distance_m: float - weight: float -class LocationCoefResponse(BaseModel): - """Ответ GET /api/v1/trade-in/location-coef (#2045 BE-3, LocationDrawer). +class LocationIndexResponse(BaseModel): + """Ответ GET /api/v1/trade-in/location-index — замена сломанного location-coef. - coef — MVP-эвристика (НЕ откалибрована на реальных ценовых дельтах, см. - app/services/location_coef.py::_score_to_coef), диапазон [0.95, 1.05]. - result_price_rub = round(base_price_rub * coef). + ИСТОРИЯ: старый `location-coef` (`coef = 0.95 + score/100*0.10`, range [0.95,1.05], + `result_price_rub = round(base_price_rub * coef)`) не был откалиброван на ценах — 67% из + 1500 адресов ЕКБ попадали в ±1%, а бакеты coef НЕ монотонны относительно медианы ₽/м² по + 4000 активным лотам (дороже — не значит выше coef). Полностью заменён. - geo_source="unavailable" — osm_poi_ekb_local пуста/не отрефрешена на этом окружении - (graceful fallback: coef=1.0, factors=[], НЕ 500 и НЕ сфабрикованные факторы). + location_index_pct — % отклонения медианы ₽/м² сопоставимых активных листингов в радиусе + точки от медианы ₽/м² по всему Екатеринбургу (см. app/services/location_index.py). НЕ + зажат искусственно — диапазон реальный. НЕ участвует в цене (estimator.py про него не + знает: аналоги уже несут локацию в базовой цене, повторное умножение — double-count). + + status: + - "ok" — location_index_pct/local_median_price_per_m2 надёжны. + - "out_of_coverage" — точка вне гео-охвата продукта (Екатеринбург). Все числовые + поля None — честный прочерк на фронте, НЕ 0%. + - "insufficient_data" — даже на максимальном радиусе сопоставимых активных листингов + меньше порога (см. MIN_SAMPLE_SIZE). Числовые поля None, но sample_size/radius_m + показывают, что реально нашлось. + + poi_status — независимый статус для nearby_poi: "ok" | "unavailable" (osm_poi_ekb_local + ещё не отрефрешена на этом окружении — пустой список, НЕ сфабрикованные точки). """ - coef: float - factors: list[LocationCoefFactorOut] - geo_source: str - base_price_rub: int - result_price_rub: int + status: str + location_index_pct: float | None + local_median_price_per_m2: int | None + city_median_price_per_m2: int | None + sample_size: int + radius_m: int + nearby_poi: list[NearbyPoiOut] + poi_status: str diff --git a/tradein-mvp/backend/app/services/location_coef.py b/tradein-mvp/backend/app/services/location_coef.py deleted file mode 100644 index 0f1f44c5..00000000 --- a/tradein-mvp/backend/app/services/location_coef.py +++ /dev/null @@ -1,224 +0,0 @@ -"""Location-coefficient POI scoring for trade-in estimates (#2045 BE-3, LocationDrawer). - -Ported straight-line formula from Site Finder (ПТИЦА) -`backend/app/services/site_finder/poi_score.py::compute_poi_weighted_top7`: - - weight = (1 / (distance_m + 100)) * CATEGORY_WEIGHTS[category] - -Reads from the LOCAL mirror table `osm_poi_ekb_local` (populated by -`app/tasks/osm_poi_ekb_refresh.py` from the `gendesign_osm_poi_ekb` FDW — see migrations -168-170). We deliberately do NOT query the FDW directly per-request: the same per-row cost -measured for the analogous cadastral-buildings FDW (~1.16s/row without a geom index on the -remote) would make a synchronous endpoint unusable. - -Scope (MVP, #2045 BE-3): - - Straight-line distance only. The ORS routing-decay mode from Site Finder - (`compute_poi_routing_decay`) is NOT ported — no ORS infrastructure in trade-in, out of - MVP scope. - - Radius tuned for apartments (1000-1500m), NOT Site Finder's 2000m default for land parcels. - - The score→coef mapping (`_score_to_coef`) is a NEW MVP heuristic, not present in Site - Finder (there POI score is a ranking metric, not a price multiplier) — see its docstring. -""" - -from __future__ import annotations - -import logging -from typing import Any - -from pydantic import BaseModel -from sqlalchemy import text - -logger = logging.getLogger(__name__) - -# Веса по категории — скопированы as-is из Site Finder CATEGORY_WEIGHTS -# (backend/app/services/site_finder/poi_score.py), чтобы ranking POI был согласован -# между продуктами. -CATEGORY_WEIGHTS: dict[str, float] = { - "metro_stop": 6.0, - "school": 5.0, - "kindergarten": 4.5, - "hospital": 4.0, - "shop_mall": 4.0, - "shop_supermarket": 3.5, - "bus_stop": 4.5, - "park": 3.5, - "pharmacy": 2.5, - "tram_stop": 2.0, - "shop_small": 2.0, - "default": 1.0, -} - -# Радиус подобран для КВАРТИР (МКД), а не для участков (Ptica default 2000м) — -# пешая доступность в пределах квартала/микрорайона. -DEFAULT_RADIUS_M = 1200 -DEFAULT_TOP_N = 7 - -# Теоретический максимум суммы весов top-7 POI. -# -# ИСТОРИЯ (audit R2 #7a): раньше максимум считался при d=0 (все top-7 категорий прямо у -# порога) — недостижимо на практике. Аудит показал, что даже сильная центральная локация в -# Екб (транспорт/садик/супермаркет рядом, школа/метро/ТЦ уже заметно дальше — типичный -# профиль квартала, реалистичные дистанции по категориям ~80-700м) даёт raw_sum лишь -# ~0.08-0.095, т.е. score ~25-30/100 при старой нормировке (/100 при d=0) → coef ~0.975-0.98. -# Сильная локация систематически читалась как "немного снижает стоимость" — знаменатель -# сжимал реалистичные scores в нижнюю треть шкалы 0-100. -# -# ФИКС: нормируем не на d=0, а на РЕФЕРЕНСНОЕ расстояние _REF_DISTANCE_M — top-7 весов, -# как если бы каждая категория была на этом расстоянии. REF=100м ровно удваивает -# знаменатель (distance_m + 100): 100+100=200 вместо 0+100=100 → теоретический максимум -# вдвое ниже прежнего → реалистичные scores вдвое выше: сильный центр ~25-30 → ~50-60 -# (coef ~1.00-1.01), с запасом выше 1.0 для исключительно плотных локаций (все top-7 -# категорий <150м). Слабые/разреженные локации остаются далеко ниже 1.0; пустая -# osm_poi_ekb_local (рефреш не запускался) по-прежнему даёт coef=1.0 через отдельный -# graceful-fallback путь в compute_location_coef, эту нормировку не трогающий. -# -# ВНИМАНИЕ: REF_DIST — калибровочная константа для MVP-эвристики (см. _score_to_coef), -# НЕ откалибрована на реальных ценовых дельтах — только на распределении реалистичных POI- -# профилей, чтобы диапазон [0.95, 1.05] соответствовал интуиции "сильный центр ≈ 1.0". -# -# Top-7 категорий по убыванию веса: 6.0+5.0+4.5+4.5+4.0+4.0+3.5 = 31.5 (тот же набор, что у Ptica). -_TOP7_WEIGHT_SUM: float = sum(sorted(CATEGORY_WEIGHTS.values(), reverse=True)[:7]) -_REF_DISTANCE_M: float = 100.0 -_MAX_STRAIGHT_SCORE: float = _TOP7_WEIGHT_SUM / ( - _REF_DISTANCE_M + 100.0 -) # ≈ 0.1575 (было 0.315 при d=0) - -# coef диапазон ±5% вокруг 1.0 — heuristic v1, НЕ откалибровано на реальных ценовых дельтах -# (в отличие от Ptica, где poi_weighted_score — ранжирующая метрика, не ценовой множитель). -_COEF_BASE = 0.95 -_COEF_SPREAD = 0.10 - - -def _category_weight(category: str | None) -> float: - """Вернуть вес категории. Если не знаем — default.""" - return CATEGORY_WEIGHTS.get(category or "default", CATEGORY_WEIGHTS["default"]) - - -class LocationCoefFactor(BaseModel): - """Один POI-фактор в ответе location-coef.""" - - poi_type: str - name: str | None - distance_m: float - weight: float - - -class LocationCoefResult(BaseModel): - """Результат compute_location_coef — потребляется эндпоинтом location-coef.""" - - coef: float - factors: list[LocationCoefFactor] - geo_source: str # "osm_poi_ekb" (норма) | "unavailable" (mirror пуста/не отрефрешена) - - -def _score_to_coef(poi_weighted_score: float) -> float: - """MVP-эвристика score(0..100) → ценовой коэффициент. - - coef = 0.95 + (score/100) * 0.10 → диапазон [0.95, 1.05]. - - ВНИМАНИЕ: это НЕ откалиброванная на реальных ценовых дельтах формула — первая рабочая - эвристика для MVP location-coef. Site Finder использует ту же POI-модель как ранжирующую - метрику (poi_weighted_score), а не как прямой ценовой множитель; здесь смысл другой, - поэтому маппинг введён отдельно и явно помечен как heuristic v1. - """ - return round(_COEF_BASE + (poi_weighted_score / 100.0) * _COEF_SPREAD, 4) - - -_NEAREST_POI_SQL = text( - """ - SELECT - p.name, - p.category, - CAST( - ST_Distance( - p.geom::geography, - ST_SetSRID(ST_MakePoint(:lon, :lat), 4326)::geography - ) AS double precision - ) AS distance_m - FROM osm_poi_ekb_local p - WHERE p.geom IS NOT NULL - AND ST_DWithin( - p.geom::geography, - ST_SetSRID(ST_MakePoint(:lon, :lat), 4326)::geography, - CAST(:radius_m AS double precision) - ) - ORDER BY distance_m ASC - LIMIT :limit - """ -) - - -def compute_location_coef( - db: Any, - lat: float, - lon: float, - radius_m: int = DEFAULT_RADIUS_M, - top_n: int = DEFAULT_TOP_N, -) -> LocationCoefResult: - """Посчитать location-coef для координат (lat, lon) по POI из osm_poi_ekb_local. - - Graceful fallback: если osm_poi_ekb_local пуста (рефреш ещё не запускался на этом - окружении) — возвращает coef=1.0, factors=[], geo_source="unavailable" вместо 500 или - сфабрикованных факторов. Отсутствие POI В РАДИУСЕ у непустой таблицы — это легитимный - результат (coef=0.95, factors=[], geo_source="osm_poi_ekb"), не fallback. - - Args: - db: SQLAlchemy Session. - lat: широта целевой квартиры. - lon: долгота целевой квартиры. - radius_m: радиус поиска в метрах (default 1200 — подобран для МКД, не для участков). - top_n: количество POI, учитываемых в score (default 7). - """ - total = db.execute(text("SELECT count(*) FROM osm_poi_ekb_local")).scalar() or 0 - if total == 0: - logger.warning( - "location_coef: osm_poi_ekb_local is empty (refresh job not yet run on this " - "environment) — returning unavailable fallback, no fabricated factors" - ) - return LocationCoefResult(coef=1.0, factors=[], geo_source="unavailable") - - rows = ( - db.execute( - _NEAREST_POI_SQL, - {"lat": lat, "lon": lon, "radius_m": radius_m, "limit": top_n * 10}, - ) - .mappings() - .all() - ) - - scored: list[tuple[float, LocationCoefFactor]] = [] - for row in rows: - distance_m = float(row["distance_m"]) - category = row["category"] or "default" - weight = (1.0 / (distance_m + 100.0)) * _category_weight(category) - scored.append( - ( - weight, - LocationCoefFactor( - poi_type=category, - name=row["name"], - distance_m=round(distance_m, 1), - weight=round(weight, 6), - ), - ) - ) - - scored.sort(key=lambda pair: pair[0], reverse=True) - top_factors = [factor for _weight, factor in scored[:top_n]] - - raw_sum = sum(factor.weight for factor in top_factors) - poi_weighted_score = min(100.0, (raw_sum / _MAX_STRAIGHT_SCORE) * 100.0) - coef = _score_to_coef(poi_weighted_score) - - logger.debug( - "location_coef: lat=%.5f lon=%.5f radius=%dm poi_found=%d top=%d " "score=%.1f coef=%.4f", - lat, - lon, - radius_m, - len(rows), - len(top_factors), - poi_weighted_score, - coef, - ) - - return LocationCoefResult(coef=coef, factors=top_factors, geo_source="osm_poi_ekb") diff --git a/tradein-mvp/backend/app/services/location_index.py b/tradein-mvp/backend/app/services/location_index.py new file mode 100644 index 00000000..7b38d30e --- /dev/null +++ b/tradein-mvp/backend/app/services/location_index.py @@ -0,0 +1,436 @@ +"""Location index for trade-in estimates — replaces the broken `location_coef` (LocationDrawer). + +ИСТОРИЯ / ПОЧЕМУ ПЕРЕПИСАНО: +Старый `location_coef.py` считал `coef = 0.95 + (poi_weighted_score/100) * 0.10` — диапазон +жёстко зажат в [0.95, 1.05], без какой-либо калибровки на реальных ценах. Аудит на боевой БД +(1500 адресов ЕКБ + 4000 активных лотов) показал: + - 67% адресов попадали в −1%…+1%, у ~25% coef был РОВНО 1.0 (score=50) — почти + неинформативно, весь город умещался в −4%…+5%; + - связи с ценой не было вообще: медиана ₽/м² по бакетам coef плоская и НЕ монотонна + (бакет −4% дороже бакета +3%). +Для сравнения, расстояние до центра ЕКБ на 31 тыс. лотов даёт чистый монотонный градиент +(0-2км 249 686 ₽/м² → 12-13км 93 677 ₽/м², разброс 2.7×) — сигнал в данных есть, просто +POI-score его не улавливал (POI ranking ≠ цена). + +НОВЫЙ ПОКАЗАТЕЛЬ (location index): + location_index_pct = (медиана ₽/м² сопоставимых активных листингов в радиусе точки − + медиана ₽/м² по всему ЕКБ) / медиана по ЕКБ * 100 + +Самообновляем (те же `listings`, что уже скрейпятся под estimator), интерпретируем напрямую +("район на N% дороже/дешевле среднего по городу"), устойчив к выбросам (percentile_cont(0.5) — +медиана самой природой игнорирует единичные экстремумы, в отличие от mean/min/max), и НЕ зажат +искусственно — если район реально на 40% дороже, так и покажет. + +ЧЕСТНАЯ ДЕГРАДАЦИЯ (см. LocationIndexResult.status): + - "out_of_coverage" — точка вне гео-охвата продукта (bbox Екатеринбурга). НЕ 0%, НЕ + fallback-число — прочерк на фронте. + - "insufficient_data" — даже на максимальном радиусе выборки < MIN_SAMPLE_SIZE сопоставимых + активных листингов. Тоже прочерк, а не шум по 3 объявлениям. + - "ok" — index надёжен. + +В ЦЕНУ НЕ ИДЁТ: estimator.py про этот модуль не знает и не должен знать — аналоги уже берутся +из того же района (локация учтена в базовой цене через сам подбор сопоставимых объектов), +повторное умножение на локационный индекс было бы двойным учётом одного и того же эффекта. + +POI («что рядом» — школа/метро/остановка) сохранены как ОТДЕЛЬНАЯ качественная справка +(`nearby_poi`, ранжирование как раньше в location_coef.py), но больше не участвуют в числовом +показателе. +""" + +from __future__ import annotations + +import logging +from typing import Any + +from pydantic import BaseModel +from sqlalchemy import text + +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 + + +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 + + +# ── Калибровочные константы (радиус / минимальная выборка) ────────────────── +# +# Плотность-прикидка для обоснования порядка величины (НЕ подтверждено живым запросом к +# прод-БД в этом изменении — см. PR description "непроверенное"): ЕКБ-аудит насчитал ~4000 +# активных лотов в bbox площадью ~ 27.8км (0.25° широты) × 15.3км (0.25° долготы на широте +# 56.8°) ≈ 425 км² → плотность ~9.4 лота/км². Круг радиусом 800м имеет площадь ~2.01 км² → +# ожидаемо ~19 лотов при равномерной плотности — близко к MIN_SAMPLE_SIZE=20, т.е. стартовый +# радиус разумен для "средней" точки. Плотность в городе крайне неравномерна (центр много +# гуще окраин) — поэтому лестница радиусов расширяется, а не фиксированный радиус. +RADIUS_LADDER_M: tuple[int, ...] = (800, 1500, 2500) + +# Ниже этого числа сопоставимых активных листингов медиана — шум, не показатель. +# Порог не откалиброван статистически (например через доверительный интервал медианы) — +# первая рабочая оценка для MVP. TODO: перепроверить на реальном распределении выборок по +# районам ЕКБ (см. "непроверенное" в отчёте задачи). +MIN_SAMPLE_SIZE = 20 + +# Санитарные (НЕ бизнес-калибровочные) границы ₽/м² — отсекают заведомо битые скрейп-строки +# (парсинг ошибся на порядок и т.п.), не сужают реальный рынок ЕКБ (там диапазон примерно +# 40-400 тыс₽/м², с большим запасом по краям). +_PRICE_PER_M2_SANITY_MIN = 20_000 +_PRICE_PER_M2_SANITY_MAX = 1_000_000 + +DEFAULT_POI_RADIUS_M = 1200 # как в старом location_coef.py — подобран для МКД +DEFAULT_POI_TOP_N = 7 + +# Веса по категории POI — те же, что были в location_coef.py (ranking "что рядом", +# больше НЕ конвертируются в число, влияющее на индекс). +CATEGORY_WEIGHTS: dict[str, float] = { + "metro_stop": 6.0, + "school": 5.0, + "kindergarten": 4.5, + "hospital": 4.0, + "shop_mall": 4.0, + "shop_supermarket": 3.5, + "bus_stop": 4.5, + "park": 3.5, + "pharmacy": 2.5, + "tram_stop": 2.0, + "shop_small": 2.0, + "default": 1.0, +} + + +def _category_weight(category: str | None) -> float: + """Вернуть вес категории. Если не знаем — default.""" + return CATEGORY_WEIGHTS.get(category or "default", CATEGORY_WEIGHTS["default"]) + + +class NearbyPoi(BaseModel): + """Один пункт «что рядом» — качественная справка, НЕ участвует в location_index_pct.""" + + poi_type: str + name: str | None + distance_m: float + + +class LocationIndexResult(BaseModel): + """Результат compute_location_index — потребляется эндпоинтом location-index.""" + + status: str # "ok" | "out_of_coverage" | "insufficient_data" + location_index_pct: float | None + local_median_price_per_m2: int | None + city_median_price_per_m2: int | None + sample_size: int + radius_m: int + nearby_poi: list[NearbyPoi] + poi_status: str # "ok" | "unavailable" (osm_poi_ekb_local пуста/не отрефрешена) + + +def _pct_deviation(local_median_ppm2: float, city_median_ppm2: float) -> float: + """% отклонения локальной медианы от городской. + + Округление до 1 знака — не создаёт ложной точности (исходные данные — шумные скрейп-цены). + """ + if city_median_ppm2 <= 0: + # Защита от деления на ноль при вырожденной городской выборке — не должно + # случаться в проде (там ~4000 активных лотов), только в пустой dev-БД. + return 0.0 + return round((local_median_ppm2 - city_median_ppm2) / city_median_ppm2 * 100.0, 1) + + +# ── SQL: медиана ₽/м² сопоставимых активных листингов ──────────────────────── +# +# percentile_cont(0.5) — тот же идиом, что уже используется в estimator.py для медианных +# ₽/м² трендов (_fetch_price_trend) — устойчив к выбросам В ОТЛИЧИЕ от AVG/min/max: единичный +# аномально дорогой/дешёвый лот не сдвигает медиану заметно. +# +# geo_precision IS DISTINCT FROM 'city' — тот же фильтр, что в estimator.py (#769 Part E): +# исключает листинги с геокодом до центра города (city-centroid fallback без номера дома), +# которые иначе "подмешивались" бы в любой радиус вокруг центра. +# +# price_per_m2 BETWEEN sanity-границы — не бизнес-калибровка, а защита от битых строк +# (см. _PRICE_PER_M2_SANITY_MIN/MAX выше). +# +# bbox-фильтр (lat/lon) — сопоставимые листинги считаются ТОЛЬКО по Екатеринбургу, даже если +# сам продукт уже скрейпит соседние города области (city-sweep): географию location_index +# явно ограничил владелец продукта. +_MEDIAN_PPM2_LOCAL_SQL = text( + """ + SELECT + CAST(percentile_cont(0.5) WITHIN GROUP (ORDER BY price_per_m2) AS double precision) + AS median_ppm2, + count(*) AS n + FROM listings + WHERE is_active = true + AND price_per_m2 IS NOT NULL + AND price_per_m2 BETWEEN CAST(:price_min AS integer) AND CAST(:price_max AS integer) + AND (geo_precision IS DISTINCT FROM 'city') + 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) + AND CAST(:bbox_east AS double precision) + AND ST_DWithin( + geom::geography, + ST_SetSRID(ST_MakePoint(:lon, :lat), 4326)::geography, + CAST(:radius_m AS double precision) + ) + """ +) + +_MEDIAN_PPM2_CITYWIDE_SQL = text( + """ + SELECT + CAST(percentile_cont(0.5) WITHIN GROUP (ORDER BY price_per_m2) AS double precision) + AS median_ppm2, + count(*) AS n + FROM listings + WHERE is_active = true + AND price_per_m2 IS NOT NULL + AND price_per_m2 BETWEEN CAST(:price_min AS integer) AND CAST(:price_max AS integer) + AND (geo_precision IS DISTINCT FROM 'city') + 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) + AND CAST(:bbox_east AS double precision) + """ +) + +_NEAREST_POI_SQL = text( + """ + SELECT + p.name, + p.category, + CAST( + ST_Distance( + p.geom::geography, + ST_SetSRID(ST_MakePoint(:lon, :lat), 4326)::geography + ) AS double precision + ) AS distance_m + FROM osm_poi_ekb_local p + WHERE p.geom IS NOT NULL + AND ST_DWithin( + p.geom::geography, + ST_SetSRID(ST_MakePoint(:lon, :lat), 4326)::geography, + CAST(:radius_m AS double precision) + ) + ORDER BY distance_m ASC + LIMIT :limit + """ +) + + +def _local_median_ppm2(db: Any, lat: float, lon: float, radius_m: int) -> tuple[float | None, int]: + row = ( + db.execute( + _MEDIAN_PPM2_LOCAL_SQL, + { + "lat": lat, + "lon": lon, + "radius_m": radius_m, + "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, + }, + ) + .mappings() + .first() + ) + if row is None: + return None, 0 + median = row["median_ppm2"] + 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]: + row = ( + db.execute( + _MEDIAN_PPM2_CITYWIDE_SQL, + { + "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, + }, + ) + .mappings() + .first() + ) + if row is None: + return None, 0 + median = row["median_ppm2"] + return (float(median) if median is not None else None), int(row["n"] or 0) + + +def _fetch_nearby_poi( + db: Any, lat: float, lon: float, radius_m: int, top_n: int +) -> tuple[list[NearbyPoi], str]: + """Top-N POI поблизости — качественная справка «что рядом», не числовой показатель. + + Graceful fallback: osm_poi_ekb_local пуста (рефреш ещё не запускался на этом окружении) + → ([], "unavailable") вместо 500 или сфабрикованного списка. + """ + total = db.execute(text("SELECT count(*) FROM osm_poi_ekb_local")).scalar() or 0 + if total == 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" + ) + return [], "unavailable" + + rows = ( + db.execute( + _NEAREST_POI_SQL, + {"lat": lat, "lon": lon, "radius_m": radius_m, "limit": top_n * 10}, + ) + .mappings() + .all() + ) + + ranked: list[tuple[float, NearbyPoi]] = [] + for row in rows: + distance_m = float(row["distance_m"]) + category = row["category"] or "default" + weight = (1.0 / (distance_m + 100.0)) * _category_weight(category) + ranked.append( + ( + weight, + NearbyPoi(poi_type=category, name=row["name"], distance_m=round(distance_m, 1)), + ) + ) + + ranked.sort(key=lambda pair: pair[0], reverse=True) + return [poi for _weight, poi in ranked[:top_n]], "ok" + + +def compute_location_index( + db: Any, + lat: float, + lon: float, + *, + radius_m: int | None = None, + poi_radius_m: int = DEFAULT_POI_RADIUS_M, + poi_top_n: int = DEFAULT_POI_TOP_N, +) -> LocationIndexResult: + """Посчитать location index для координат (lat, lon). + + location_index_pct = (медиана ₽/м² сопоставимых активных листингов в радиусе точки − + медиана ₽/м² по всему ЕКБ) / медиана по ЕКБ * 100. Радиус — лестница RADIUS_LADDER_M + (расширяется, пока выборка не наберёт MIN_SAMPLE_SIZE), если явный radius_m не передан + (тогда используется РОВНО он, без расширения — для отладки/тестов). + + Args: + db: SQLAlchemy Session. + lat: широта целевой точки. + lon: долгота целевой точки. + radius_m: явный радиус в метрах — если задан, лестница не используется. + poi_radius_m: радиус для качественного списка «что рядом» (независим от индекса). + poi_top_n: сколько POI показать в «что рядом». + + Returns: + LocationIndexResult со status: + - "out_of_coverage" — точка вне bbox Екатеринбурга, ничего не считаем. + - "insufficient_data" — даже на максимальном радиусе сопоставимых листингов + меньше MIN_SAMPLE_SIZE (или городская выборка-эталон сама вырождена). + - "ok" — location_index_pct надёжен. + """ + if not _in_ekb_bbox(lat, lon): + logger.info( + "location_index: lat=%.5f lon=%.5f outside EKB coverage bbox — out_of_coverage", + lat, + lon, + ) + return LocationIndexResult( + status="out_of_coverage", + location_index_pct=None, + local_median_price_per_m2=None, + city_median_price_per_m2=None, + sample_size=0, + radius_m=radius_m or RADIUS_LADDER_M[0], + nearby_poi=[], + poi_status="unavailable", + ) + + nearby_poi, poi_status = _fetch_nearby_poi(db, lat, lon, poi_radius_m, poi_top_n) + + city_median, city_n = _citywide_median_ppm2(db) + if city_median is None or city_n < MIN_SAMPLE_SIZE: + logger.warning( + "location_index: citywide reference sample too small (n=%d) — insufficient_data", + city_n, + ) + return LocationIndexResult( + status="insufficient_data", + location_index_pct=None, + local_median_price_per_m2=None, + city_median_price_per_m2=(round(city_median) if city_median is not None else None), + sample_size=city_n, + radius_m=radius_m or RADIUS_LADDER_M[-1], + nearby_poi=nearby_poi, + poi_status=poi_status, + ) + + radii = [radius_m] if radius_m is not None else list(RADIUS_LADDER_M) + local_median: float | None = None + sample_size = 0 + used_radius = radii[-1] + for r in radii: + local_median, sample_size = _local_median_ppm2(db, lat, lon, r) + used_radius = r + if sample_size >= MIN_SAMPLE_SIZE: + break + + if local_median is None or sample_size < MIN_SAMPLE_SIZE: + logger.info( + "location_index: lat=%.5f lon=%.5f sample=%d < MIN_SAMPLE_SIZE=%d up to " + "radius=%dm — insufficient_data", + lat, + lon, + sample_size, + MIN_SAMPLE_SIZE, + used_radius, + ) + return LocationIndexResult( + status="insufficient_data", + location_index_pct=None, + local_median_price_per_m2=None, + city_median_price_per_m2=round(city_median), + sample_size=sample_size, + radius_m=used_radius, + nearby_poi=nearby_poi, + poi_status=poi_status, + ) + + pct = _pct_deviation(local_median, city_median) + logger.debug( + "location_index: lat=%.5f lon=%.5f radius=%dm n=%d local=%d city=%d pct=%.1f", + lat, + lon, + used_radius, + sample_size, + round(local_median), + round(city_median), + pct, + ) + return LocationIndexResult( + status="ok", + location_index_pct=pct, + local_median_price_per_m2=round(local_median), + city_median_price_per_m2=round(city_median), + sample_size=sample_size, + radius_m=used_radius, + nearby_poi=nearby_poi, + poi_status=poi_status, + ) diff --git a/tradein-mvp/backend/app/tasks/osm_poi_ekb_refresh.py b/tradein-mvp/backend/app/tasks/osm_poi_ekb_refresh.py index 1b0d2ee6..35a93499 100644 --- a/tradein-mvp/backend/app/tasks/osm_poi_ekb_refresh.py +++ b/tradein-mvp/backend/app/tasks/osm_poi_ekb_refresh.py @@ -1,4 +1,4 @@ -"""OSM POI local-mirror refresh (#2045 BE-3, LocationDrawer location-coef). +"""OSM POI local-mirror refresh (#2045 BE-3, LocationDrawer location-index). Populates `osm_poi_ekb_local` (empty at deploy, migration 169) via a single bulk scan of the `gendesign_osm_poi_ekb` FDW foreign table (migration 168 — live view of gendesign's @@ -8,9 +8,11 @@ of #2045, already merged + deployed on gendesign). WHY a local mirror (perf fact measured for the analogous gendesign_cad_buildings FDW — see `app/tasks/cadastral_geo_match.py`): a per-request FDW nearest-POI query pays a per-row FDW round-trip (~1.16s/row without a geom index on the remote table) — UNUSABLE for a synchronous -endpoint (`GET /api/v1/trade-in/location-coef`). We materialize the FDW once (single bulk +endpoint (`GET /api/v1/trade-in/location-index`). We materialize the FDW once (single bulk scan) into `osm_poi_ekb_local` with a real Point geom + GIST index, then -`app/services/location_coef.py` runs fast LOCAL ST_DWithin/ST_Distance queries per estimate. +`app/services/location_index.py` runs fast LOCAL ST_DWithin/ST_Distance queries per estimate +(nearby-POI qualitative list only — the numeric index itself comes from `listings`, not POI; +see that module's docstring for the location-coef → location-index rewrite history). Scheduler source='osm_poi_ekb_refresh' (daily — OSM POI data changes rarely). Pure internal DB op — one FDW read + local TRUNCATE+INSERT, no HTTP/anti-bot. diff --git a/tradein-mvp/backend/tests/services/test_location_coef.py b/tradein-mvp/backend/tests/services/test_location_coef.py deleted file mode 100644 index db29025a..00000000 --- a/tradein-mvp/backend/tests/services/test_location_coef.py +++ /dev/null @@ -1,229 +0,0 @@ -"""Unit tests for app.services.location_coef (#2045 BE-3, LocationDrawer). - -No live Postgres needed — DB is a minimal fake returning queued results (mirrors the -convention in tests/tasks/test_cadastral_geo_match.py). Covers: - - pure functions: _category_weight, _score_to_coef, normalization constants - - compute_location_coef: weighted top-N scoring, empty-mirror graceful fallback, - no-POI-in-radius (legit zero-score, NOT "unavailable") -""" - -from __future__ import annotations - -import os -from typing import Any - -# psycopg v3 driver required; stub DATABASE_URL before any app import (settings needs a DSN). -os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") - -from app.services import location_coef as lc - -# ── Pure functions ────────────────────────────────────────────────────────── - - -def test_category_weight_known_categories() -> None: - assert lc._category_weight("metro_stop") == 6.0 - assert lc._category_weight("school") == 5.0 - assert lc._category_weight("kindergarten") == 4.5 - assert lc._category_weight("hospital") == 4.0 - assert lc._category_weight("shop_mall") == 4.0 - assert lc._category_weight("shop_supermarket") == 3.5 - assert lc._category_weight("bus_stop") == 4.5 - assert lc._category_weight("park") == 3.5 - assert lc._category_weight("pharmacy") == 2.5 - assert lc._category_weight("tram_stop") == 2.0 - assert lc._category_weight("shop_small") == 2.0 - - -def test_category_weight_unknown_and_none_fall_back_to_default() -> None: - assert lc._category_weight("unknown_category") == 1.0 - assert lc._category_weight(None) == 1.0 - - -def test_top7_weight_sum_matches_ptica() -> None: - """Same category set as Site Finder → identical top-7 weight-sum constant (31.5).""" - assert lc._TOP7_WEIGHT_SUM == 31.5 - - -def test_max_straight_score_normalized_at_reference_distance_not_zero() -> None: - """Audit R2 #7a: denominator uses _REF_DISTANCE_M (100m), not an unreachable d=0 max. - - Old d=0 normalization was _TOP7_WEIGHT_SUM / 100 == 0.315 — the theoretical max with all - top-7 categories AT the doorstep. That compressed realistic scores (~25-30/100 for even a - strong central address) into the bottom third of the range. The new normalization halves - the max (denominator distance+100 doubles from 100 to 200 at REF=100m), doubling realistic - scores instead. - """ - assert lc._REF_DISTANCE_M == 100.0 - assert abs(lc._MAX_STRAIGHT_SCORE - 0.1575) < 1e-9 - - -def test_score_to_coef_bounds() -> None: - assert lc._score_to_coef(0.0) == 0.95 - assert lc._score_to_coef(100.0) == 1.05 - - -def test_score_to_coef_midpoint() -> None: - assert lc._score_to_coef(50.0) == 1.0 - - -def test_score_to_coef_is_monotonic() -> None: - scores = [0.0, 10.0, 25.0, 50.0, 75.0, 90.0, 100.0] - coefs = [lc._score_to_coef(s) for s in scores] - assert coefs == sorted(coefs) - - -# ── compute_location_coef with a fake DB ───────────────────────────────────── - - -class _FakeResult: - def __init__(self, *, scalar_value: Any = None, mapping_rows: list[dict] | None = None): - self._scalar_value = scalar_value - self._mapping_rows = mapping_rows or [] - - def scalar(self) -> Any: - return self._scalar_value - - def mappings(self) -> Any: - class _Mappings: - def __init__(self, rows: list[dict]) -> None: - self._rows = rows - - def all(self) -> list[dict]: - return self._rows - - return _Mappings(self._mapping_rows) - - -class _FakeDB: - """Minimal Session stand-in: execute() returns queued results in order.""" - - def __init__(self, results: list[_FakeResult]) -> None: - self._results = list(results) - self.executed: list[Any] = [] - - def execute(self, clause: Any, params: dict | None = None) -> _FakeResult: - self.executed.append((clause, params)) - return self._results.pop(0) - - -def test_compute_location_coef_empty_mirror_returns_unavailable() -> None: - """osm_poi_ekb_local not yet refreshed (count=0) → unavailable, no fabricated factors.""" - db = _FakeDB([_FakeResult(scalar_value=0)]) - result = lc.compute_location_coef(db, lat=56.84, lon=60.6) - assert result.coef == 1.0 - assert result.factors == [] - assert result.geo_source == "unavailable" - # Only the count probe ran — no nearest-POI query issued against an empty mirror. - assert len(db.executed) == 1 - - -def test_compute_location_coef_no_poi_in_radius_is_legit_zero_score() -> None: - """Mirror populated (count>0) but nothing within radius → coef floor, NOT unavailable.""" - db = _FakeDB( - [ - _FakeResult(scalar_value=500), # mirror has rows elsewhere - _FakeResult(mapping_rows=[]), # nothing near this point - ] - ) - result = lc.compute_location_coef(db, lat=56.84, lon=60.6) - assert result.factors == [] - assert result.geo_source == "osm_poi_ekb" - assert result.coef == lc._score_to_coef(0.0) == 0.95 - - -def test_compute_location_coef_weights_and_ranks_top_n() -> None: - """Nearer + higher-weight-category POI ranks above farther/lower-weight ones.""" - rows = [ - {"name": "Школа №1", "category": "school", "distance_m": 300.0}, - {"name": "ТЦ Мега", "category": "shop_mall", "distance_m": 900.0}, - {"name": "Метро Ботаническая", "category": "metro_stop", "distance_m": 150.0}, - {"name": "Аптека", "category": "pharmacy", "distance_m": 50.0}, - ] - db = _FakeDB([_FakeResult(scalar_value=1000), _FakeResult(mapping_rows=rows)]) - result = lc.compute_location_coef(db, lat=56.84, lon=60.6, top_n=7) - - assert result.geo_source == "osm_poi_ekb" - assert len(result.factors) == 4 - # metro_stop (weight 6.0) at 150m beats school (5.0) at 300m despite being closer only - # marginally — sanity check the ranking is weight-driven, not distance-only. - assert result.factors[0].poi_type == "metro_stop" - # Weights strictly descending (sorted DESC by weight before slicing to top_n). - weights = [f.weight for f in result.factors] - assert weights == sorted(weights, reverse=True) - # coef must land inside the documented [0.95, 1.05] MVP range. - assert 0.95 <= result.coef <= 1.05 - - -def test_compute_location_coef_strong_central_fixture_lands_near_one() -> None: - """Audit R2 #7a: a realistic strong central-EKB POI profile now reads as ~1.0, not ~0.98. - - Fixture mirrors a real strong central address audit: near transit/kindergarten/pharmacy, - but school/metro/mall noticeably farther (a typical quarter profile, NOT everything at - the doorstep). Under the OLD d=0 normalization this fixture scores ~28/100 → coef ~0.978 - (verified separately against the pre-fix formula). After re-centering on _REF_DISTANCE_M - it should land close to 1.0 (within the documented ±0.01 calibration target). - """ - rows = [ - {"name": "Остановка", "category": "bus_stop", "distance_m": 80.0}, - {"name": "Аптека", "category": "pharmacy", "distance_m": 120.0}, - {"name": "Супермаркет", "category": "shop_supermarket", "distance_m": 180.0}, - {"name": "Детсад №5", "category": "kindergarten", "distance_m": 220.0}, - {"name": "Школа №10", "category": "school", "distance_m": 350.0}, - {"name": "Метро Геологическая", "category": "metro_stop", "distance_m": 500.0}, - {"name": "ТЦ Гринвич", "category": "shop_mall", "distance_m": 700.0}, - ] - db = _FakeDB([_FakeResult(scalar_value=len(rows)), _FakeResult(mapping_rows=rows)]) - result = lc.compute_location_coef(db, lat=56.838, lon=60.605, top_n=7) - - assert result.geo_source == "osm_poi_ekb" - assert len(result.factors) == 7 - assert abs(result.coef - 1.0) <= 0.01 - - -def test_compute_location_coef_weak_fixture_stays_well_below_one() -> None: - """A sparse/poor location (only far, low-weight POI) must still stay well below 1.0.""" - rows = [ - {"name": "Магазинчик", "category": "shop_small", "distance_m": 950.0}, - {"name": "Прочее", "category": "some_unknown_tag", "distance_m": 1100.0}, - ] - db = _FakeDB([_FakeResult(scalar_value=len(rows)), _FakeResult(mapping_rows=rows)]) - result = lc.compute_location_coef(db, lat=56.838, lon=60.605, top_n=7) - - assert result.geo_source == "osm_poi_ekb" - assert result.coef < 0.98 - - -def test_compute_location_coef_limits_to_top_n() -> None: - """More than top_n candidates → only top_n factors surface in the response.""" - rows = [ - {"name": f"POI {i}", "category": "shop_small", "distance_m": float(100 + i * 10)} - for i in range(20) - ] - db = _FakeDB([_FakeResult(scalar_value=20), _FakeResult(mapping_rows=rows)]) - result = lc.compute_location_coef(db, lat=56.84, lon=60.6, top_n=7) - assert len(result.factors) == 7 - - -def test_compute_location_coef_unknown_category_uses_default_weight() -> None: - rows = [{"name": "Неизвестный POI", "category": "some_new_osm_tag", "distance_m": 200.0}] - db = _FakeDB([_FakeResult(scalar_value=1), _FakeResult(mapping_rows=rows)]) - result = lc.compute_location_coef(db, lat=56.84, lon=60.6) - assert len(result.factors) == 1 - expected_weight = (1.0 / (200.0 + 100.0)) * lc.CATEGORY_WEIGHTS["default"] - assert abs(result.factors[0].weight - round(expected_weight, 6)) < 1e-9 - - -def test_compute_location_coef_passes_radius_param() -> None: - """radius_m is forwarded as a bound param (psycopg v3 CAST discipline, no :p::type).""" - db = _FakeDB([_FakeResult(scalar_value=1), _FakeResult(mapping_rows=[])]) - lc.compute_location_coef(db, lat=56.84, lon=60.6, radius_m=1500) - _clause, params = db.executed[1] - assert params is not None - assert params["radius_m"] == 1500 - - -def test_no_psycopg_v3_colon_colon_cast() -> None: - """psycopg v3: never :param::type — must use CAST(:param AS type).""" - import re - - assert not re.search(r":\w+::", str(lc._NEAREST_POI_SQL.text)) diff --git a/tradein-mvp/backend/tests/services/test_location_index.py b/tradein-mvp/backend/tests/services/test_location_index.py new file mode 100644 index 00000000..63c0be4b --- /dev/null +++ b/tradein-mvp/backend/tests/services/test_location_index.py @@ -0,0 +1,346 @@ +"""Unit tests for app.services.location_index (replaces test_location_coef.py). + +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) + - _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 + - SQL discipline: psycopg v3 CAST, percentile_cont (not naive AVG/MIN/MAX) for outlier + robustness +""" + +from __future__ import annotations + +import os +from typing import Any + +# psycopg v3 driver required; stub DATABASE_URL before any app import (settings needs a DSN). +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +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 + +# ── Pure functions ────────────────────────────────────────────────────────── + + +def test_category_weight_known_categories() -> None: + assert lc._category_weight("metro_stop") == 6.0 + assert lc._category_weight("school") == 5.0 + assert lc._category_weight("kindergarten") == 4.5 + assert lc._category_weight("hospital") == 4.0 + assert lc._category_weight("shop_mall") == 4.0 + assert lc._category_weight("shop_supermarket") == 3.5 + assert lc._category_weight("bus_stop") == 4.5 + assert lc._category_weight("park") == 3.5 + assert lc._category_weight("pharmacy") == 2.5 + assert lc._category_weight("tram_stop") == 2.0 + assert lc._category_weight("shop_small") == 2.0 + + +def test_category_weight_unknown_and_none_fall_back_to_default() -> None: + assert lc._category_weight("unknown_category") == 1.0 + assert lc._category_weight(None) == 1.0 + + +def test_in_ekb_bbox_center_is_inside() -> None: + assert lc._in_ekb_bbox(_LAT_IN_EKB, _LON_IN_EKB) is True + + +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 + + +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 + # 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 + + +def test_pct_deviation_above_and_below_city_median() -> None: + assert lc._pct_deviation(165_000.0, 150_000.0) == 10.0 + assert lc._pct_deviation(135_000.0, 150_000.0) == -10.0 + assert lc._pct_deviation(150_000.0, 150_000.0) == 0.0 + + +def test_pct_deviation_not_artificially_clamped() -> None: + """Owner requirement: a genuinely +40% district must read as +40%, not clamped.""" + assert lc._pct_deviation(210_000.0, 150_000.0) == 40.0 + + +def test_pct_deviation_guards_zero_division() -> None: + assert lc._pct_deviation(100_000.0, 0.0) == 0.0 + + +def test_pct_deviation_is_monotonic_in_local_median() -> None: + """Индекс строго монотонен по локальной медиане при фиксированной городской — в отличие + от старого coef (немонотонные бакеты на реальных данных, см. модуль docstring). + + Значения ниже — медианы ₽/м² по дистанционным бакетам от центра ЕКБ, измеренные на 31 + тыс. лотов (аудит владельца продукта), отсортированные по возрастанию. Индекс, + построенный на этих же локальных медианах, обязан сохранить порядок. + """ + city_median = 155_000.0 + local_medians_ascending = [ + 93_677.0, + 136_729.0, + 150_063.0, + 159_382.0, + 159_486.0, + 191_682.0, + 249_686.0, + ] + pct_values = [lc._pct_deviation(m, city_median) for m in local_medians_ascending] + assert pct_values == sorted(pct_values) + + +# ── SQL discipline ──────────────────────────────────────────────────────────── + + +def test_no_psycopg_v3_colon_colon_cast() -> None: + """psycopg v3: never :param::type — must use CAST(:param AS type).""" + import re + + for sql in ( + lc._MEDIAN_PPM2_LOCAL_SQL, + lc._MEDIAN_PPM2_CITYWIDE_SQL, + lc._NEAREST_POI_SQL, + ): + assert not re.search(r":\w+::", str(sql.text)) + + +def test_median_queries_use_percentile_not_naive_minmax() -> None: + """Outlier robustness requirement: percentile_cont(0.5) (median), not AVG/MIN/MAX.""" + for sql in (lc._MEDIAN_PPM2_LOCAL_SQL, lc._MEDIAN_PPM2_CITYWIDE_SQL): + sql_text = str(sql.text).lower() + assert "percentile_cont(0.5)" in sql_text + assert "avg(" not in sql_text + assert "min(" not in sql_text + assert "max(" not in sql_text + + +def test_median_queries_exclude_city_centroid_and_bound_bbox() -> None: + """Comparable-selection quality control (owner requirement #1): city-centroid geocodes + excluded (mirrors estimator.py #769 Part E), sample bounded to the EKB bbox.""" + for sql in (lc._MEDIAN_PPM2_LOCAL_SQL, lc._MEDIAN_PPM2_CITYWIDE_SQL): + sql_text = str(sql.text) + assert "geo_precision IS DISTINCT FROM 'city'" in sql_text + assert "bbox_south" in sql_text and "bbox_north" in sql_text + assert "bbox_west" in sql_text and "bbox_east" in sql_text + + +# ── _fetch_nearby_poi (qualitative "что рядом" list) ───────────────────────── + + +class _FakeResult: + def __init__( + self, + *, + scalar_value: Any = None, + mapping_rows: list[dict] | None = None, + mapping_one: dict | None = None, + ): + self._scalar_value = scalar_value + self._mapping_rows = mapping_rows or [] + self._mapping_one = mapping_one + + def scalar(self) -> Any: + return self._scalar_value + + def mappings(self) -> Any: + outer = self + + class _Mappings: + def all(self) -> list[dict]: + return outer._mapping_rows + + def first(self) -> dict | None: + return outer._mapping_one + + return _Mappings() + + +class _FakeDB: + """Minimal Session stand-in: execute() returns queued results in order.""" + + def __init__(self, results: list[_FakeResult]) -> None: + self._results = list(results) + self.executed: list[Any] = [] + + def execute(self, clause: Any, params: dict | None = None) -> _FakeResult: + self.executed.append((clause, params)) + return self._results.pop(0) + + +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) + assert poi == [] + assert status == "unavailable" + assert len(db.executed) == 1 # only the count probe ran + + +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) + assert poi == [] + assert status == "ok" + + +def test_fetch_nearby_poi_ranks_by_weight_not_distance_only() -> None: + rows = [ + {"name": "Школа №1", "category": "school", "distance_m": 300.0}, + {"name": "ТЦ Мега", "category": "shop_mall", "distance_m": 900.0}, + {"name": "Метро Ботаническая", "category": "metro_stop", "distance_m": 150.0}, + {"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) + assert status == "ok" + assert len(poi) == 4 + # metro_stop (weight 6.0) at 150m outranks school (5.0) at 300m — weight-driven, not + # distance-only ranking. + assert poi[0].poi_type == "metro_stop" + + +def test_fetch_nearby_poi_limits_to_top_n() -> None: + rows = [ + {"name": f"POI {i}", "category": "shop_small", "distance_m": float(100 + i * 10)} + 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) + assert len(poi) == 7 + + +# ── compute_location_index: degradation + ladder logic ─────────────────────── + + +def test_compute_location_index_out_of_coverage_skips_all_db_calls() -> None: + """Owner requirement #2: point outside EKB → honest 'no data', never a fallback number. + + Also a perf/honesty check: no DB round-trip at all for an out-of-scope point. + """ + db = _FakeDB([]) + result = lc.compute_location_index(db, lat=57.910, lon=59.970) # Nizhny Tagil + assert result.status == "out_of_coverage" + assert result.location_index_pct is None + assert result.local_median_price_per_m2 is None + assert result.city_median_price_per_m2 is None + assert result.sample_size == 0 + assert result.nearby_poi == [] + assert result.poi_status == "unavailable" + assert db.executed == [] + + +def test_compute_location_index_citywide_sample_too_small_short_circuits() -> None: + """Degenerate citywide reference (e.g. empty dev DB) → insufficient_data without ever + issuing a local-radius query (nothing to compare against anyway).""" + db = _FakeDB( + [ + _FakeResult(scalar_value=0), # POI mirror empty + _FakeResult(mapping_one={"median_ppm2": None, "n": 3}), # citywide: n < MIN + ] + ) + result = lc.compute_location_index(db, lat=_LAT_IN_EKB, lon=_LON_IN_EKB) + assert result.status == "insufficient_data" + assert result.location_index_pct is None + assert result.sample_size == 3 + assert len(db.executed) == 2 # poi-count + citywide only — no radius-ladder queries + + +def test_compute_location_index_first_radius_rung_sufficient() -> None: + db = _FakeDB( + [ + _FakeResult(scalar_value=0), # poi mirror empty + _FakeResult(mapping_one={"median_ppm2": 150_000.0, "n": 4000}), # citywide + _FakeResult(mapping_one={"median_ppm2": 165_000.0, "n": 25}), # radius[0]=800 + ] + ) + result = lc.compute_location_index(db, lat=_LAT_IN_EKB, lon=_LON_IN_EKB) + assert result.status == "ok" + assert result.radius_m == lc.RADIUS_LADDER_M[0] + assert result.sample_size == 25 + assert result.local_median_price_per_m2 == 165_000 + assert result.city_median_price_per_m2 == 150_000 + assert result.location_index_pct == 10.0 + assert len(db.executed) == 3 # ladder stopped at rung 1 — no further radius queries + + +def test_compute_location_index_expands_ladder_when_first_rung_insufficient() -> None: + db = _FakeDB( + [ + _FakeResult(scalar_value=0), + _FakeResult(mapping_one={"median_ppm2": 150_000.0, "n": 4000}), + _FakeResult(mapping_one={"median_ppm2": 200_000.0, "n": 10}), # 800m: too few + _FakeResult(mapping_one={"median_ppm2": 180_000.0, "n": 30}), # 1500m: enough + ] + ) + result = lc.compute_location_index(db, lat=_LAT_IN_EKB, lon=_LON_IN_EKB) + assert result.status == "ok" + assert result.radius_m == lc.RADIUS_LADDER_M[1] + assert result.sample_size == 30 + assert len(db.executed) == 4 + + +def test_compute_location_index_insufficient_even_at_max_radius() -> None: + db = _FakeDB( + [ + _FakeResult(scalar_value=0), + _FakeResult(mapping_one={"median_ppm2": 150_000.0, "n": 4000}), + _FakeResult(mapping_one={"median_ppm2": 200_000.0, "n": 5}), # 800m + _FakeResult(mapping_one={"median_ppm2": 195_000.0, "n": 12}), # 1500m + _FakeResult(mapping_one={"median_ppm2": 190_000.0, "n": 15}), # 2500m — still < 20 + ] + ) + result = lc.compute_location_index(db, lat=_LAT_IN_EKB, lon=_LON_IN_EKB) + assert result.status == "insufficient_data" + assert result.location_index_pct is None + assert result.local_median_price_per_m2 is None + assert result.city_median_price_per_m2 == 150_000 + assert result.radius_m == lc.RADIUS_LADDER_M[-1] + assert result.sample_size == 15 # honest: shows how close it got, not just "no data" + assert len(db.executed) == 5 # exhausted the full ladder + + +def test_compute_location_index_explicit_radius_skips_ladder() -> None: + """An explicit radius_m must be used AS-IS — no adaptive expansion (caller-controlled).""" + db = _FakeDB( + [ + _FakeResult(scalar_value=0), + _FakeResult(mapping_one={"median_ppm2": 150_000.0, "n": 4000}), + _FakeResult(mapping_one={"median_ppm2": 160_000.0, "n": 50}), # single query only + ] + ) + result = lc.compute_location_index(db, lat=_LAT_IN_EKB, lon=_LON_IN_EKB, radius_m=1000) + assert result.status == "ok" + assert result.radius_m == 1000 + assert len(db.executed) == 3 # exactly one radius query, no ladder rungs tried + nearest_call_params = db.executed[2][1] + assert nearest_call_params["radius_m"] == 1000 + + +def test_compute_location_index_poi_unavailable_does_not_block_index() -> None: + """poi_status and status degrade INDEPENDENTLY — an empty POI mirror must not prevent a + perfectly computable price-based index.""" + db = _FakeDB( + [ + _FakeResult(scalar_value=0), # poi mirror empty + _FakeResult(mapping_one={"median_ppm2": 150_000.0, "n": 4000}), + _FakeResult(mapping_one={"median_ppm2": 172_500.0, "n": 40}), + ] + ) + result = lc.compute_location_index(db, lat=_LAT_IN_EKB, lon=_LON_IN_EKB) + assert result.status == "ok" + assert result.poi_status == "unavailable" + assert result.nearby_poi == [] + assert result.location_index_pct == 15.0 diff --git a/tradein-mvp/backend/tests/test_location_coef_endpoint.py b/tradein-mvp/backend/tests/test_location_coef_endpoint.py deleted file mode 100644 index 5a8ddb3a..00000000 --- a/tradein-mvp/backend/tests/test_location_coef_endpoint.py +++ /dev/null @@ -1,251 +0,0 @@ -"""Tests for GET /api/v1/trade-in/location-coef (#2045 BE-3, LocationDrawer). - -Mirrors the IDOR + mocked-DB conventions of test_estimate_idor.py / test_street_deals_endpoint.py -(no live Postgres needed). Covers: - - IDOR guard (owner / other pilot 404 / admin / unauthenticated 401) - - graceful fallback: osm_poi_ekb_local empty/not-refreshed, estimate has no lat/lon - - happy path: base_price_rub * coef → result_price_rub, factors surfaced - - radius_m query-param clamping -""" - -from __future__ import annotations - -import os -import sys -from types import SimpleNamespace -from unittest.mock import MagicMock - -# psycopg v3 driver required; stub DATABASE_URL before any app import. -os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") - -# WeasyPrint requires GTK — not present in CI/Windows. Stub before any app import -# (trade_in.py imports generate_trade_in_pdf at module load). -_wp_mock = MagicMock() -sys.modules.setdefault("weasyprint", _wp_mock) -sys.modules.setdefault("weasyprint.CSS", _wp_mock) -sys.modules.setdefault("weasyprint.HTML", _wp_mock) - -import pytest # noqa: E402 -from fastapi import FastAPI # noqa: E402 -from fastapi.testclient import TestClient # noqa: E402 - -_ESTIMATE_ID = "22222222-2222-2222-2222-222222222222" - - -@pytest.fixture(autouse=True) -def _restore_get_role(): - """Restore app.core.auth.get_role after each test (mirror test_estimate_idor).""" - from app.core import auth as auth_mod - - original = auth_mod.get_role - yield - auth_mod.get_role = original - - -@pytest.fixture() -def trade_in_app() -> FastAPI: - """Minimal FastAPI app mounting only the trade-in router.""" - from app.api.v1 import trade_in as trade_in_module - - application = FastAPI() - application.include_router(trade_in_module.router, prefix="/api/v1/trade-in") - return application - - -def _client_with(app: FastAPI, db_mock: MagicMock, role: str | None) -> TestClient: - """Override get_db with *db_mock*; patch get_role to return *role* (or raise KeyError).""" - from app.core.db import get_db - - def _override_db(): - yield db_mock - - app.dependency_overrides[get_db] = _override_db - - auth_mod = sys.modules["app.core.auth"] - if role is None: - - def _raise_keyerror(_u: str): - raise KeyError(_u) - - auth_mod.get_role = _raise_keyerror # type: ignore[assignment] - else: - auth_mod.get_role = lambda _u: role # type: ignore[assignment] - return TestClient(app) - - -def _fetchone_result(row: object) -> MagicMock: - r = MagicMock() - r.fetchone.return_value = row - return r - - -def _scalar_result(value: object) -> MagicMock: - r = MagicMock() - r.scalar.return_value = value - return r - - -def _mapping_result(rows: list[dict]) -> MagicMock: - r = MagicMock() - r.mappings.return_value.all.return_value = rows - return r - - -def _db_with(*results: MagicMock) -> MagicMock: - db = MagicMock() - db.execute.side_effect = list(results) - return db - - -# ── IDOR guard ──────────────────────────────────────────────────────────────── - - -def test_location_coef_other_pilot_gets_404(trade_in_app: FastAPI) -> None: - """Non-owner pilot must NOT read someone else's location-coef → 404.""" - db = _db_with(_fetchone_result(SimpleNamespace(created_by="victim"))) - client = _client_with(trade_in_app, db, role="pilot") - resp = client.get( - "/api/v1/trade-in/location-coef", - params={"estimate_id": _ESTIMATE_ID}, - headers={"X-Authenticated-User": "attacker"}, - ) - assert resp.status_code == 404 - - -def test_location_coef_requires_authenticated_user(trade_in_app: FastAPI) -> None: - """No X-Authenticated-User header → 401 (guard query already ran, header check fails).""" - db = _db_with(_fetchone_result(SimpleNamespace(created_by="kopylov"))) - client = _client_with(trade_in_app, db, role="pilot") - resp = client.get( - "/api/v1/trade-in/location-coef", - params={"estimate_id": _ESTIMATE_ID}, - ) - assert resp.status_code == 401 - - -def test_location_coef_unknown_estimate_returns_404(trade_in_app: FastAPI) -> None: - db = _db_with(_fetchone_result(None)) - client = _client_with(trade_in_app, db, role="pilot") - resp = client.get( - "/api/v1/trade-in/location-coef", - params={"estimate_id": _ESTIMATE_ID}, - headers={"X-Authenticated-User": "kopylov"}, - ) - assert resp.status_code == 404 - - -def test_location_coef_admin_can_read_any(trade_in_app: FastAPI) -> None: - """Admin reads any estimate's location-coef regardless of owner → 200.""" - db = _db_with( - _fetchone_result(SimpleNamespace(created_by="someone_else")), # guard - _fetchone_result(SimpleNamespace(lat=None, lon=None, median_price=5_000_000)), # target - ) - client = _client_with(trade_in_app, db, role="admin") - resp = client.get( - "/api/v1/trade-in/location-coef", - params={"estimate_id": _ESTIMATE_ID}, - headers={"X-Authenticated-User": "admin"}, - ) - assert resp.status_code == 200 - - -# ── Graceful fallback ─────────────────────────────────────────────────────── - - -def test_location_coef_no_lat_lon_returns_unavailable(trade_in_app: FastAPI) -> None: - """Estimate without lat/lon → unavailable fallback, no osm_poi_ekb_local query at all.""" - db = _db_with( - _fetchone_result(SimpleNamespace(created_by="kopylov")), # guard - _fetchone_result(SimpleNamespace(lat=None, lon=None, median_price=5_000_000)), # target - ) - client = _client_with(trade_in_app, db, role="pilot") - resp = client.get( - "/api/v1/trade-in/location-coef", - params={"estimate_id": _ESTIMATE_ID}, - headers={"X-Authenticated-User": "kopylov"}, - ) - assert resp.status_code == 200 - body = resp.json() - assert body["geo_source"] == "unavailable" - assert body["coef"] == 1.0 - assert body["factors"] == [] - assert body["base_price_rub"] == 5_000_000 - assert body["result_price_rub"] == 5_000_000 - # Short-circuits before compute_location_coef: only guard + target queries ran. - assert db.execute.call_count == 2 - - -def test_location_coef_empty_mirror_returns_unavailable(trade_in_app: FastAPI) -> None: - """osm_poi_ekb_local not yet refreshed (count=0) → unavailable, no fabricated factors.""" - db = _db_with( - _fetchone_result(SimpleNamespace(created_by="kopylov")), # guard - _fetchone_result(SimpleNamespace(lat=56.84, lon=60.6, median_price=5_000_000)), # target - _scalar_result(0), # osm_poi_ekb_local count - ) - client = _client_with(trade_in_app, db, role="pilot") - resp = client.get( - "/api/v1/trade-in/location-coef", - params={"estimate_id": _ESTIMATE_ID}, - headers={"X-Authenticated-User": "kopylov"}, - ) - assert resp.status_code == 200 - body = resp.json() - assert body["geo_source"] == "unavailable" - assert body["coef"] == 1.0 - assert body["factors"] == [] - assert body["result_price_rub"] == body["base_price_rub"] - - -# ── Happy path ──────────────────────────────────────────────────────────────── - - -def test_location_coef_happy_path_computes_result_price(trade_in_app: FastAPI) -> None: - """POI found within radius → coef applied to base_price_rub, factors surfaced.""" - poi_rows = [ - {"name": "Школа №1", "category": "school", "distance_m": 300.0}, - {"name": "Метро Ботаническая", "category": "metro_stop", "distance_m": 150.0}, - ] - db = _db_with( - _fetchone_result(SimpleNamespace(created_by="kopylov")), # guard - _fetchone_result(SimpleNamespace(lat=56.84, lon=60.6, median_price=5_000_000)), # target - _scalar_result(1000), # osm_poi_ekb_local count - _mapping_result(poi_rows), # nearest POI - ) - client = _client_with(trade_in_app, db, role="pilot") - resp = client.get( - "/api/v1/trade-in/location-coef", - params={"estimate_id": _ESTIMATE_ID}, - headers={"X-Authenticated-User": "kopylov"}, - ) - assert resp.status_code == 200 - body = resp.json() - assert body["geo_source"] == "osm_poi_ekb" - assert body["base_price_rub"] == 5_000_000 - assert 0.95 <= body["coef"] <= 1.05 - assert body["result_price_rub"] == round(5_000_000 * body["coef"]) - assert len(body["factors"]) == 2 - poi_types = {f["poi_type"] for f in body["factors"]} - assert poi_types == {"school", "metro_stop"} - - -def test_location_coef_radius_m_clamped_to_bounds(trade_in_app: FastAPI) -> None: - """Explicit radius_m outside [500, 3000] is clamped before hitting the DB query.""" - db = _db_with( - _fetchone_result(SimpleNamespace(created_by="kopylov")), # guard - _fetchone_result(SimpleNamespace(lat=56.84, lon=60.6, median_price=5_000_000)), # target - _scalar_result(10), # osm_poi_ekb_local count - _mapping_result([]), # nearest POI query - ) - client = _client_with(trade_in_app, db, role="pilot") - resp = client.get( - "/api/v1/trade-in/location-coef", - params={"estimate_id": _ESTIMATE_ID, "radius_m": 50}, - headers={"X-Authenticated-User": "kopylov"}, - ) - assert resp.status_code == 200 - # 4th execute() call is the nearest-POI query — inspect its bound radius_m param. - nearest_call = db.execute.call_args_list[3] - params = ( - nearest_call.args[1] if len(nearest_call.args) > 1 else nearest_call.kwargs.get("params") - ) - assert params["radius_m"] == 500 diff --git a/tradein-mvp/backend/tests/test_location_index_endpoint.py b/tradein-mvp/backend/tests/test_location_index_endpoint.py new file mode 100644 index 00000000..1bd97a5c --- /dev/null +++ b/tradein-mvp/backend/tests/test_location_index_endpoint.py @@ -0,0 +1,325 @@ +"""Tests for GET /api/v1/trade-in/location-index (replaces test_location_coef_endpoint.py). + +Mirrors the IDOR + mocked-DB conventions of test_estimate_idor.py / the deleted +test_location_coef_endpoint.py (no live Postgres needed). Covers: + - IDOR guard (owner / other pilot 404 / admin / unauthenticated 401) + - honest degradation: no lat/lon, point outside EKB coverage, insufficient comparable + sample, empty osm_poi_ekb_local mirror (independent of the index status) + - happy path: status="ok", location_index_pct computed, nearby_poi surfaced + - radius_m query-param clamping +""" + +from __future__ import annotations + +import os +import sys +from types import SimpleNamespace +from unittest.mock import MagicMock + +# psycopg v3 driver required; stub DATABASE_URL before any app import. +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +# WeasyPrint requires GTK — not present in CI/Windows. Stub before any app import +# (trade_in.py imports generate_trade_in_pdf at module load). +_wp_mock = MagicMock() +sys.modules.setdefault("weasyprint", _wp_mock) +sys.modules.setdefault("weasyprint.CSS", _wp_mock) +sys.modules.setdefault("weasyprint.HTML", _wp_mock) + +import pytest # noqa: E402 +from fastapi import FastAPI # noqa: E402 +from fastapi.testclient import TestClient # noqa: E402 + +_ESTIMATE_ID = "22222222-2222-2222-2222-222222222222" +_LAT_IN_EKB = 56.838 +_LON_IN_EKB = 60.605 + + +@pytest.fixture(autouse=True) +def _restore_get_role(): + """Restore app.core.auth.get_role after each test (mirror test_estimate_idor).""" + from app.core import auth as auth_mod + + original = auth_mod.get_role + yield + auth_mod.get_role = original + + +@pytest.fixture() +def trade_in_app() -> FastAPI: + """Minimal FastAPI app mounting only the trade-in router.""" + from app.api.v1 import trade_in as trade_in_module + + application = FastAPI() + application.include_router(trade_in_module.router, prefix="/api/v1/trade-in") + return application + + +def _client_with(app: FastAPI, db_mock: MagicMock, role: str | None) -> TestClient: + """Override get_db with *db_mock*; patch get_role to return *role* (or raise KeyError).""" + from app.core.db import get_db + + def _override_db(): + yield db_mock + + app.dependency_overrides[get_db] = _override_db + + auth_mod = sys.modules["app.core.auth"] + if role is None: + + def _raise_keyerror(_u: str): + raise KeyError(_u) + + auth_mod.get_role = _raise_keyerror # type: ignore[assignment] + else: + auth_mod.get_role = lambda _u: role # type: ignore[assignment] + return TestClient(app) + + +def _fetchone_result(row: object) -> MagicMock: + r = MagicMock() + r.fetchone.return_value = row + return r + + +def _scalar_result(value: object) -> MagicMock: + r = MagicMock() + r.scalar.return_value = value + return r + + +def _mapping_all_result(rows: list[dict]) -> MagicMock: + r = MagicMock() + r.mappings.return_value.all.return_value = rows + return r + + +def _mapping_one_result(row: dict | None) -> MagicMock: + r = MagicMock() + r.mappings.return_value.first.return_value = row + return r + + +def _db_with(*results: MagicMock) -> MagicMock: + db = MagicMock() + db.execute.side_effect = list(results) + return db + + +# ── IDOR guard ──────────────────────────────────────────────────────────────── + + +def test_location_index_other_pilot_gets_404(trade_in_app: FastAPI) -> None: + """Non-owner pilot must NOT read someone else's location-index → 404.""" + db = _db_with(_fetchone_result(SimpleNamespace(created_by="victim"))) + client = _client_with(trade_in_app, db, role="pilot") + resp = client.get( + "/api/v1/trade-in/location-index", + params={"estimate_id": _ESTIMATE_ID}, + headers={"X-Authenticated-User": "attacker"}, + ) + assert resp.status_code == 404 + + +def test_location_index_requires_authenticated_user(trade_in_app: FastAPI) -> None: + """No X-Authenticated-User header → 401 (guard query already ran, header check fails).""" + db = _db_with(_fetchone_result(SimpleNamespace(created_by="kopylov"))) + client = _client_with(trade_in_app, db, role="pilot") + resp = client.get( + "/api/v1/trade-in/location-index", + params={"estimate_id": _ESTIMATE_ID}, + ) + assert resp.status_code == 401 + + +def test_location_index_unknown_estimate_returns_404(trade_in_app: FastAPI) -> None: + db = _db_with(_fetchone_result(None)) + client = _client_with(trade_in_app, db, role="pilot") + resp = client.get( + "/api/v1/trade-in/location-index", + params={"estimate_id": _ESTIMATE_ID}, + headers={"X-Authenticated-User": "kopylov"}, + ) + assert resp.status_code == 404 + + +def test_location_index_admin_can_read_any(trade_in_app: FastAPI) -> None: + """Admin reads any estimate's location-index regardless of owner → 200.""" + db = _db_with( + _fetchone_result(SimpleNamespace(created_by="someone_else")), # guard + _fetchone_result(SimpleNamespace(lat=None, lon=None)), # target — no lat/lon + ) + client = _client_with(trade_in_app, db, role="admin") + resp = client.get( + "/api/v1/trade-in/location-index", + params={"estimate_id": _ESTIMATE_ID}, + headers={"X-Authenticated-User": "admin"}, + ) + assert resp.status_code == 200 + + +# ── Honest degradation ──────────────────────────────────────────────────────── + + +def test_location_index_no_lat_lon_returns_out_of_coverage(trade_in_app: FastAPI) -> None: + """Estimate without lat/lon → out_of_coverage, no compute_location_index DB call at all.""" + db = _db_with( + _fetchone_result(SimpleNamespace(created_by="kopylov")), # guard + _fetchone_result(SimpleNamespace(lat=None, lon=None)), # target + ) + client = _client_with(trade_in_app, db, role="pilot") + resp = client.get( + "/api/v1/trade-in/location-index", + params={"estimate_id": _ESTIMATE_ID}, + headers={"X-Authenticated-User": "kopylov"}, + ) + assert resp.status_code == 200 + body = resp.json() + assert body["status"] == "out_of_coverage" + assert body["location_index_pct"] is None + assert body["nearby_poi"] == [] + assert body["poi_status"] == "unavailable" + # Short-circuits before compute_location_index: only guard + target queries ran. + assert db.execute.call_count == 2 + + +def test_location_index_point_outside_ekb_bbox_returns_out_of_coverage( + trade_in_app: FastAPI, +) -> None: + """Estimate has lat/lon, but outside the EKB coverage bbox (e.g. Nizhny Tagil).""" + db = _db_with( + _fetchone_result(SimpleNamespace(created_by="kopylov")), # guard + _fetchone_result(SimpleNamespace(lat=57.910, lon=59.970)), # target — Nizhny Tagil + ) + client = _client_with(trade_in_app, db, role="pilot") + resp = client.get( + "/api/v1/trade-in/location-index", + params={"estimate_id": _ESTIMATE_ID}, + headers={"X-Authenticated-User": "kopylov"}, + ) + assert resp.status_code == 200 + body = resp.json() + assert body["status"] == "out_of_coverage" + assert body["location_index_pct"] is None + # compute_location_index short-circuits — no further DB calls beyond guard + target. + assert db.execute.call_count == 2 + + +def test_location_index_insufficient_sample_returns_no_fabricated_number( + trade_in_app: FastAPI, +) -> None: + """Comparable sample stays below MIN_SAMPLE_SIZE even at max radius → insufficient_data, + never a noisy number computed from a handful of listings.""" + db = _db_with( + _fetchone_result(SimpleNamespace(created_by="kopylov")), # guard + _fetchone_result(SimpleNamespace(lat=_LAT_IN_EKB, lon=_LON_IN_EKB)), # target + _scalar_result(0), # osm_poi_ekb_local count + _mapping_one_result({"median_ppm2": 150_000.0, "n": 4000}), # citywide + _mapping_one_result({"median_ppm2": 200_000.0, "n": 5}), # 800m + _mapping_one_result({"median_ppm2": 195_000.0, "n": 12}), # 1500m + _mapping_one_result({"median_ppm2": 190_000.0, "n": 15}), # 2500m — still short + ) + client = _client_with(trade_in_app, db, role="pilot") + resp = client.get( + "/api/v1/trade-in/location-index", + params={"estimate_id": _ESTIMATE_ID}, + headers={"X-Authenticated-User": "kopylov"}, + ) + assert resp.status_code == 200 + body = resp.json() + assert body["status"] == "insufficient_data" + assert body["location_index_pct"] is None + assert body["local_median_price_per_m2"] is None + assert body["sample_size"] == 15 + assert body["city_median_price_per_m2"] == 150_000 + + +def test_location_index_poi_mirror_empty_does_not_block_index(trade_in_app: FastAPI) -> None: + """poi_status="unavailable" is independent of status="ok" — an un-refreshed POI mirror + must not prevent a perfectly computable price-based index.""" + db = _db_with( + _fetchone_result(SimpleNamespace(created_by="kopylov")), # guard + _fetchone_result(SimpleNamespace(lat=_LAT_IN_EKB, lon=_LON_IN_EKB)), # target + _scalar_result(0), # osm_poi_ekb_local count == 0 + _mapping_one_result({"median_ppm2": 150_000.0, "n": 4000}), # citywide + _mapping_one_result({"median_ppm2": 172_500.0, "n": 40}), # 800m — sufficient + ) + client = _client_with(trade_in_app, db, role="pilot") + resp = client.get( + "/api/v1/trade-in/location-index", + params={"estimate_id": _ESTIMATE_ID}, + headers={"X-Authenticated-User": "kopylov"}, + ) + assert resp.status_code == 200 + body = resp.json() + assert body["status"] == "ok" + assert body["location_index_pct"] == 15.0 + assert body["poi_status"] == "unavailable" + assert body["nearby_poi"] == [] + + +# ── Happy path ──────────────────────────────────────────────────────────────── + + +def test_location_index_happy_path(trade_in_app: FastAPI) -> None: + """Comparable sample found → index computed, nearby POI surfaced as qualitative info.""" + poi_rows = [ + {"name": "Школа №1", "category": "school", "distance_m": 300.0}, + {"name": "Метро Ботаническая", "category": "metro_stop", "distance_m": 150.0}, + ] + db = _db_with( + _fetchone_result(SimpleNamespace(created_by="kopylov")), # guard + _fetchone_result(SimpleNamespace(lat=_LAT_IN_EKB, lon=_LON_IN_EKB)), # target + _scalar_result(1000), # osm_poi_ekb_local count + _mapping_all_result(poi_rows), # nearest POI + _mapping_one_result({"median_ppm2": 150_000.0, "n": 4000}), # citywide + _mapping_one_result({"median_ppm2": 165_000.0, "n": 25}), # 800m — sufficient + ) + client = _client_with(trade_in_app, db, role="pilot") + resp = client.get( + "/api/v1/trade-in/location-index", + params={"estimate_id": _ESTIMATE_ID}, + headers={"X-Authenticated-User": "kopylov"}, + ) + assert resp.status_code == 200 + body = resp.json() + assert body["status"] == "ok" + assert body["poi_status"] == "ok" + assert body["location_index_pct"] == 10.0 + assert body["local_median_price_per_m2"] == 165_000 + assert body["city_median_price_per_m2"] == 150_000 + assert body["sample_size"] == 25 + assert body["radius_m"] == 800 + assert len(body["nearby_poi"]) == 2 + poi_types = {f["poi_type"] for f in body["nearby_poi"]} + assert poi_types == {"school", "metro_stop"} + # New contract must NOT resurrect the misleading price-multiplier fields. + assert "coef" not in body + assert "base_price_rub" not in body + assert "result_price_rub" not in body + + +def test_location_index_radius_m_clamped_to_bounds(trade_in_app: FastAPI) -> None: + """Explicit radius_m outside [500, 3000] is clamped before hitting the DB query.""" + db = _db_with( + _fetchone_result(SimpleNamespace(created_by="kopylov")), # guard + _fetchone_result(SimpleNamespace(lat=_LAT_IN_EKB, lon=_LON_IN_EKB)), # target + _scalar_result(0), # osm_poi_ekb_local count + _mapping_one_result({"median_ppm2": 150_000.0, "n": 4000}), # citywide + _mapping_one_result({"median_ppm2": 160_000.0, "n": 50}), # explicit radius query + ) + client = _client_with(trade_in_app, db, role="pilot") + resp = client.get( + "/api/v1/trade-in/location-index", + params={"estimate_id": _ESTIMATE_ID, "radius_m": 50}, + headers={"X-Authenticated-User": "kopylov"}, + ) + assert resp.status_code == 200 + body = resp.json() + assert body["radius_m"] == 500 + # 5th execute() call (index 4) is the explicit-radius local-median query. + nearest_call = db.execute.call_args_list[4] + params = ( + nearest_call.args[1] if len(nearest_call.args) > 1 else nearest_call.kwargs.get("params") + ) + assert params["radius_m"] == 500 diff --git a/tradein-mvp/frontend/src/app/v2/page.tsx b/tradein-mvp/frontend/src/app/v2/page.tsx index 834a1a85..83b465ca 100644 --- a/tradein-mvp/frontend/src/app/v2/page.tsx +++ b/tradein-mvp/frontend/src/app/v2/page.tsx @@ -60,7 +60,7 @@ import { useEstimateMutation, useEstimatePlacementHistory, useEstimateSellTimeSensitivity, - useLocationCoef, + useLocationIndex, useSalesVsListings, useStreetDeals, } from "@/lib/trade-in-api"; @@ -134,7 +134,8 @@ const EMPTY_OBJECT: ObjectInfo = { houseType: "—", repair: "—", balcony: false, - locationCoef: "—", + locationIndexLabel: "—", + locationIndexOk: false, lat: null, lon: null, }; @@ -471,7 +472,7 @@ export default function TradeInV2Page() { // L3 — once the restore-by-id fetch has confirmed the estimate does not // exist (404), `currentEstimateId` below drops to null so the sibling - // dashboard hooks (analytics/location-coef/placement-history/sell-time, + // dashboard hooks (analytics/location-index/placement-history/sell-time, // all `enabled: estimate_id !== null`) don't each fire their own doomed // request against the same dead id. Hoisted above the sub-hooks (was // computed further down, after they'd already fired on the stale id). @@ -532,10 +533,10 @@ export default function TradeInV2Page() { estimate?.rooms ?? null, ); const analytics = useEstimateHouseAnalytics(currentEstimateId); - // LocationDrawer + HeroBar «КОЭФ. ЛОКАЦИИ» (#2317). Same independent-resolve - // contract: a pending/errored/unavailable response degrades to the mapper's - // honest "—" (mapObject/mapLocation), never a fabricated coefficient. - const locationCoef = useLocationCoef(currentEstimateId); + // LocationDrawer + HeroBar «ЛОКАЦИЯ». Same independent-resolve contract: a + // pending/errored/degraded response resolves to the mapper's own honest, + // distinct reason (mapObject/mapLocation) — never a fabricated percent. + const locationIndex = useLocationIndex(currentEstimateId); // Overlay-only sub-hooks (04 ПРОДАЖИ В ДОМЕ / 05 РЫНОК / 06 АНАЛИТИКА). Same // contract: each resolves independently; a pending/errored one degrades its // overlay section to an honest empty via the mapper (null input). @@ -549,7 +550,7 @@ export default function TradeInV2Page() { const streetDealsData = streetDeals.data ?? null; const analyticsData = analytics.data ?? null; - const locationCoefData = locationCoef.data ?? null; + const locationIndexData = locationIndex.data ?? null; const placementHistoryData = placementHistory.data ?? null; const salesVsListingsData = salesVsListings.data ?? null; const sellTimeData = sellTime.data ?? null; @@ -575,12 +576,12 @@ export default function TradeInV2Page() { [estimate], ); const objectInfo = useMemo( - () => (estimate ? mapObject(estimate, locationCoefData) : EMPTY_OBJECT), - [estimate, locationCoefData], + () => (estimate ? mapObject(estimate, locationIndexData) : EMPTY_OBJECT), + [estimate, locationIndexData], ); const locationData = useMemo( - () => mapLocation(locationCoefData), - [locationCoefData], + () => mapLocation(locationIndexData), + [locationIndexData], ); const resultPanelData = useMemo( () => (estimate ? mapResultPanel(estimate, streetDealsData) : null), diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/HeroBar.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/HeroBar.tsx index c1e77d35..0e90e627 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/HeroBar.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/v2/HeroBar.tsx @@ -194,7 +194,7 @@ interface HeroBarProps { // #2275 mobile quick-view: a real fluid layout instead of the fixed-width // desktop one — meta/buttons stack, the locator mini-map (and the // address/coef card baked into it) is dropped since it assumes a 560×152 box - // that cannot reflow. «КАК РАССЧИТАНО» still opens the same location-coef + // that cannot reflow. «КАК РАССЧИТАНО» still opens the same location-index // drawer, so no functionality is lost, only the redundant map-card copy. compact?: boolean; } @@ -441,7 +441,7 @@ export default function HeroBar({ the box is a fixed 560×152 with several absolutely-positioned children (address card) pinned to that size, so it cannot reflow to a phone width. «КАК РАССЧИТАНО» above still opens the same - location-coef drawer, so no functionality is lost. + location-index drawer, so no functionality is lost. User-reported bug: this used to be a single static building.png photo shown for EVERY estimate regardless of the real address (a user could be looking at someone else's building) — replaced with a @@ -547,7 +547,14 @@ export default function HeroBar({ onOpenInfo(); } }} - aria-label="Пояснение к расчёту коэффициента локации" + // Honest framing (post location-coef rewrite, see + // backend/app/services/location_index.py): this is a comparison + // vs. the city median, not a price multiplier, and it never + // affects the quoted estimate. title= is a plain hover tooltip + // (zero layout cost) carrying that caveat since the compact pill + // has no room to spell it out inline. + aria-label="Локация относительно города — справочно, не влияет на оценку" + title="Сравнение медианы ₽/м² района и города. На итоговую оценку не влияет." style={{ display: "flex", alignItems: "center", @@ -566,10 +573,21 @@ export default function HeroBar({ color: tokens.muted2, }} > - КОЭФ. ЛОКАЦИИ + ЛОКАЦИЯ - {data.object.locationCoef === "—" ? ( + {data.object.locationIndexOk ? ( + + {data.object.locationIndexLabel} + + ) : ( - {/* #2317: coef is a live feature now (GET /location-coef) — a - dash here means unavailable/loading for THIS estimate - (unavailable geo_source, no lat/lon, or query pending), - never "not built yet", so «скоро» would be stale/false. */} - нет данных - - ) : ( - - {data.object.locationCoef} + {/* Distinct honest reasons instead of one blank dash — see + mapObject/locationIndexBadge (./mappers.ts): "вне ЕКБ" + (out_of_coverage — index only covers Yekaterinburg), + "мало данных" (insufficient_data — too few comparable + listings), "нет данных" (not fetched yet for this + estimate). Full explanation lives in the drawer below. */} + {data.object.locationIndexLabel} )} + Данные о ближайшей инфраструктуре сейчас недоступны. + + ); + } + return ( + <> +
+ ЧТО РЯДОМ +
+ {data.factors.length > 0 ? ( + + ) : ( +
+ Объектов инфраструктуры в радиусе поиска не найдено. +
+ )} + + ); +} + interface LocationDrawerProps { open: boolean; onClose: () => void; @@ -313,142 +408,95 @@ export function LocationDrawer({ > ЛОКАЦИЯ - {data.available ? ( -
-
- Без поправки на локацию - - {data.baseLabel} - -
-
0 ? 10 : 0, - }} - > - С поправкой на локацию ({data.coefDelta}) - + {data.status === "ok" && ( + <> +
- {data.resultLabel} - -
- {/* Fix #7c (audit): the estimator never reads this coefficient — it's - illustrative/reference only. Without this line the ₽-vs-₽ layout - above reads as if it changes the quoted price. */} -
- Справочно: поправка на локацию не влияет на итоговую оценку. -
- {data.factors.length > 0 ? ( -
    - {data.factors.map((f, i) => ( -
  • - - {f.label} - {f.category !== f.label && ( - - {" "} - · {f.category} - - )} - - - {f.distance} - -
  • - ))} -
- ) : ( -
- Объектов инфраструктуры в радиусе поиска не найдено. + {locationDirectionSentence(data.indexLabel)}
- )} -
- Ориентировочная поправка по близости инфраструктуры - (OpenStreetMap) — MVP-эвристика, диапазон ±5%, не откалибрована - на реальных ценовых сделках. -
-
- ) : ( -
- Данные о ближайшей инфраструктуре для этого адреса сейчас{" "} - недоступны — коэффициент - локации не корректирует текущую оценку. -
- )} +
+ Медиана ₽/м² рядом (радиус {data.radiusLabel}) + + {data.localMedianLabel} + +
+
+ Медиана ₽/м² по Екатеринбургу + + {data.cityMedianLabel} + +
+ {/* Honest, not illustrative: this metric никогда не идёт в цену + (аналоги уже берутся из этого же района — повторный учёт + локации был бы задвоением, см. app/services/location_index.py). */} +
+ Посчитано по {data.sampleSize}{" "} + {pluralRu(data.sampleSize, [ + "объявлению", + "объявлениям", + "объявлениям", + ])}{" "} + в радиусе {data.radiusLabel}. Справочно — на итоговую оценку не + влияет: аналоги для расчёта уже берутся из этого района. +
+ + + )} + {data.status === "out_of_coverage" && ( + <> + Локационный индекс считаем только по{" "} + Екатеринбургу — этот адрес + вне зоны покрытия, сравнение с городом недоступно. + + )} + {data.status === "insufficient_data" && ( + <> + Рядом нашлось только{" "} + {data.sampleSize}{" "} + сопоставимых объявлений (радиус {data.radiusLabel}) — этого + недостаточно для надёжного сравнения с городом. + + + )} + {data.status === "loading" && "Считаем локационный индекс…"} +
); diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/fixtures.ts b/tradein-mvp/frontend/src/components/trade-in/v2/fixtures.ts index a56dbdb3..7c66e2ba 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/fixtures.ts +++ b/tradein-mvp/frontend/src/components/trade-in/v2/fixtures.ts @@ -13,7 +13,6 @@ import type { DealRow, DropdownOptions, History, - LocationFactors, MarketAds, MarketDeals, ObjectInfo, @@ -44,7 +43,8 @@ export const object: ObjectInfo = { houseType: "Панельный", repair: "Хороший", balcony: true, - locationCoef: "0.87", + locationIndexLabel: "+8%", + locationIndexOk: true, // Approximate центр Екатеринбурга near ул. Малышева, 30 — illustrative // fixture coordinate for the HeroBar locator mini-map (unwired usage only). lat: 56.8384, @@ -633,28 +633,12 @@ export const dropdownOptions: DropdownOptions = { ], }; -// ---- КОЭФ. ЛОКАЦИИ DRAWER ------------------------------------------------- - -export const locationFactors: LocationFactors = { - coef: "0.87", - intro: - "Показывает, как адрес корректирует цену относительно медианы по Екатеринбургу. 1.00 — средний уровень. 0.87 означает, что локация снижает цену примерно на 13% из-за баланса факторов ниже.", - formula: { base: "11,29 млн", coef: "0.87", result: "9,82 млн ₽" }, - positives: [ - { label: "Центр города, пешая доступность ключевых точек", delta: "+0.06" }, - { label: "Транспортные узлы и остановки рядом", delta: "+0.05" }, - { label: "Набережная и парк в 10 минутах", delta: "+0.04" }, - { label: "Развитая торговая инфраструктура", delta: "+0.03" }, - ], - negatives: [ - { label: "Оживлённая магистраль, шумовая нагрузка", delta: "−0.07" }, - { label: "Дефицит парковочных мест", delta: "−0.05" }, - { label: "Возраст жилого фонда района (1985)", delta: "−0.04" }, - { label: "Износ инженерных сетей квартала", delta: "−0.02" }, - ], - footer: - "Источник геоданных: OpenStreetMap POI, транспортная доступность, шумовые и экологические слои. Коэффициент пересчитывается при смене адреса.", -}; +// Note: the pre-wiring `locationFactors` fixture (fabricated 0.87 coefficient +// + base/coef/result formula + positives/negatives) lived here — removed +// together with the location-index rewrite. LocationDrawer now reads +// `LocationData` produced by `mapLocation` (./mappers.ts) from the real +// GET /trade-in/location-index response; its own default/unwired fixture is +// LOCATION_FIXTURE in ./LocationDrawer.tsx. // ---- NAV / CHROME --------------------------------------------------------- diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts b/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts index eac886b6..f65f89d7 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts +++ b/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts @@ -18,8 +18,9 @@ // source groupBy) — out of scope for #2040/#2041, left as-is. // BE-2 target_address is a single string; street/city are split heuristically // (parseAddress). Backend should return structured address components. -// BE-3 location coefficient shipped 2026-07-03 (#2045) and is wired here -// (mapObject/mapLocation consume GET /trade-in/location-coef, #2317). +// BE-3 location index (replacing the broken location-coef, see +// backend/app/services/location_index.py) is wired here +// (mapObject/mapLocation consume GET /trade-in/location-index). // // Enum <-> RU reconciliation (design dropdowns have options with no enum value): // house type: 'Блочный' ⇄ enum 'other' (enum has no dedicated block type) @@ -34,7 +35,7 @@ import type { HouseAnalyticsKpi, HouseAnalyticsResponse, HouseType, - LocationCoefResponse, + LocationIndexResponse, PlacementHistoryItem, RepairState, SalesVsListingsResponse, @@ -783,29 +784,39 @@ export function mapReport(e: AggregatedEstimate): Report { } /** - * Location-coefficient delta label shared by mapObject (HeroBar tile) and - * mapLocation (LocationDrawer). coef is an MVP heuristic in [0.95, 1.05] - * (backend location_coef.py::_score_to_coef, NOT calibrated on real price - * deltas) — surfaced as a whole-percent delta via fmtPct (same rounding as the - * other honest deltas on this page), never a raw multiplier that would read as - * more precise than it is. "—" while absent/loading AND when - * geo_source="unavailable" (legitimate graceful fallback, not an error). + * Compact HeroBar badge for the location index — {label, ok} shared by + * mapObject (ObjectInfo.locationIndexLabel/locationIndexOk) and available for + * reuse. location_index_pct is a real % deviation of the local median ₽/м² + * from the citywide median (see backend/app/services/location_index.py) — a + * comparison metric, NOT a price multiplier, and it does NOT feed the + * estimate. `ok: true` only for status="ok" (a trustworthy percent); every + * other case gets a short, honest, DISTINCT reason instead of a blank dash — + * the drawer (mapLocation below) expands on each: + * - li == null → "нет данных" (not fetched yet / this estimate + * has no location index request enabled) + * - "out_of_coverage" → "вне ЕКБ" (the index only covers Yekaterinburg) + * - "insufficient_data" → "мало данных" (too few comparable listings) */ -function coefDeltaLabel(lc: LocationCoefResponse | null | undefined): string { - if (lc == null || lc.geo_source === "unavailable") return "—"; - return fmtPct((lc.coef - 1) * 100); +function locationIndexBadge( + li: LocationIndexResponse | null | undefined, +): { label: string; ok: boolean } { + if (li == null) return { label: "нет данных", ok: false }; + if (li.status === "ok") return { label: fmtPct(li.location_index_pct), ok: true }; + if (li.status === "out_of_coverage") return { label: "вне ЕКБ", ok: false }; + return { label: "мало данных", ok: false }; // status === "insufficient_data" } /** * Object snapshot (ParamsPanel inputs + HeroBar/ObjectSummary address block). - * `coef` is the GET /trade-in/location-coef response (#2317) — optional/null - * while it is still loading or unavailable for this address. + * `locationIndex` is the GET /trade-in/location-index response — optional/null + * while it is still loading or not requested for this address. */ export function mapObject( e: AggregatedEstimate, - coef?: LocationCoefResponse | null, + locationIndex?: LocationIndexResponse | null, ): ObjectInfo { const { address, city } = parseAddress(e.target_address); + const badge = locationIndexBadge(locationIndex); return { address, city, @@ -817,7 +828,8 @@ export function mapObject( houseType: e.house_type ? HOUSE_TYPE_RU[e.house_type] : "—", repair: e.repair_state ? REPAIR_RU[e.repair_state] : "—", balcony: e.has_balcony ?? false, - locationCoef: coefDeltaLabel(coef), + locationIndexLabel: badge.label, + locationIndexOk: badge.ok, // Same target_lat/target_lon the ParamsPanel/SourcesMap map pins use — // null while the estimate has no geocode yet. lat: e.target_lat, @@ -825,10 +837,11 @@ export function mapObject( }; } -// ── Location factors (LocationDrawer «ЛОКАЦИЯ») ───────────────────────────── +// ── Location index (LocationDrawer «ЛОКАЦИЯ») ─────────────────────────────── // RU category label per OSM POI type, mirroring the CATEGORY_WEIGHTS keys in -// backend/app/services/location_coef.py (top7 straight-line POI score). An -// unrecognised category (raw OSM tag outside that dict) falls back to a +// backend/app/services/location_index.py (top-N straight-line POI ranking — +// a qualitative "what's nearby" list only, no longer feeding any score/coef). +// An unrecognised category (raw OSM tag outside that dict) falls back to a // generic label rather than surfacing a raw enum-ish string to the user. const POI_CATEGORY_RU: Record = { metro_stop: "Метро", @@ -849,51 +862,71 @@ function poiCategoryLabel(poiType: string): string { return POI_CATEGORY_RU[poiType] ?? POI_CATEGORY_FALLBACK; } -/** One POI row in the LocationDrawer factor list. */ +/** One POI row in the LocationDrawer «что рядом» list. */ export interface LocationFactorRow { label: string; // POI name if known, else its RU category category: string; // RU category label (always present, for the badge) distance: string; // "150 м" / "1.2 км" } +/** + * LocationDrawer «ЛОКАЦИЯ» section data. `status` mirrors the backend's own + * three-way honest-degradation contract ("loading" is an FE-only 4th state + * for li == null, e.g. query still pending) so the drawer can explain EACH + * case differently instead of collapsing them into one dash: + * - "ok" index/medians reliable, show the real numbers. + * - "out_of_coverage" address outside Yekaterinburg — the index simply + * doesn't cover it (not an error, not "no data"). + * - "insufficient_data" too few comparable listings even at the widest + * radius — sampleSize/radiusLabel still say what was actually found. + * - "loading" not fetched yet. + * indexLabel/localMedianLabel/cityMedianLabel are "—" whenever the + * corresponding backend field is null (never a fabricated number). + */ export interface LocationData { - // true when the backend actually computed a coefficient for this address - // (geo_source="osm_poi_ekb"), even if no POI were found within radius - // (factors=[] is then a legitimate empty result, not "no data"). - available: boolean; - coefDelta: string; // same formatting as ObjectInfo.locationCoef, "—" if unavailable - baseLabel: string; // base_price_rub before the location adjustment, "X млн ₽" - resultLabel: string; // result_price_rub = round(base_price_rub * coef), "X млн ₽" + status: "ok" | "out_of_coverage" | "insufficient_data" | "loading"; + indexLabel: string; // fmtPct(location_index_pct), "—" if not status="ok" + localMedianLabel: string; // fmtPpm(local_median_price_per_m2), "—" if null + cityMedianLabel: string; // fmtPpm(city_median_price_per_m2), "—" if null + sampleSize: number; // comparable active listings actually found (0 if n/a) + radiusLabel: string; // fmtDist(radius_m), "—" while loading + poiAvailable: boolean; // poi_status === "ok" (independent of status above) factors: LocationFactorRow[]; } /** - * LocationDrawer «ЛОКАЦИЯ» section data from GET /trade-in/location-coef - * (#2317). null/undefined/geo_source="unavailable" all degrade to an honest - * unavailable state — never a fabricated coefficient, price or factor list - * (mirrors the backend's own graceful-fallback contract). + * LocationDrawer «ЛОКАЦИЯ» section data from GET /trade-in/location-index. + * null/undefined (not fetched yet) degrades to status="loading" — never a + * fabricated index, price or factor list (mirrors the backend's own + * graceful-fallback contract). */ export function mapLocation( - lc: LocationCoefResponse | null | undefined, + li: LocationIndexResponse | null | undefined, ): LocationData { - if (lc == null || lc.geo_source === "unavailable") { + if (li == null) { return { - available: false, - coefDelta: "—", - baseLabel: "—", - resultLabel: "—", + status: "loading", + indexLabel: "—", + localMedianLabel: "—", + cityMedianLabel: "—", + sampleSize: 0, + radiusLabel: "—", + poiAvailable: false, factors: [], }; } return { - available: true, - coefDelta: coefDeltaLabel(lc), - baseLabel: `${fmtMln(lc.base_price_rub)} млн ₽`, - resultLabel: `${fmtMln(lc.result_price_rub)} млн ₽`, - factors: lc.factors.map((f) => ({ - label: f.name?.trim() || poiCategoryLabel(f.poi_type), - category: poiCategoryLabel(f.poi_type), - distance: fmtDist(f.distance_m), + status: li.status, + indexLabel: li.status === "ok" ? fmtPct(li.location_index_pct) : "—", + localMedianLabel: fmtPpm(li.local_median_price_per_m2), + cityMedianLabel: fmtPpm(li.city_median_price_per_m2), + sampleSize: li.sample_size, + radiusLabel: fmtDist(li.radius_m), + poiAvailable: li.poi_status === "ok", + factors: li.nearby_poi.map((p) => ({ + label: p.name?.trim() || poiCategoryLabel(p.poi_type), + category: poiCategoryLabel(p.poi_type), + distance: fmtDist(p.distance_m), })), }; } diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/types.ts b/tradein-mvp/frontend/src/components/trade-in/v2/types.ts index 4db48392..3151b60c 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/types.ts +++ b/tradein-mvp/frontend/src/components/trade-in/v2/types.ts @@ -18,7 +18,15 @@ export interface ObjectInfo { houseType: string; repair: string; balcony: boolean; - locationCoef: string; + // Compact HeroBar badge for the location index (see mapObject / mappers.ts + // locationIndexBadge): "+12%"/"−8%" when the backend has a reliable value + // (status="ok"), else a short honest reason ("вне ЕКБ" / "мало данных" / + // "нет данных" while loading) — never a fabricated percent. + locationIndexLabel: string; + // true only when locationIndexLabel is a real percent (status="ok") — tells + // HeroBar whether to render it as the accent numeric value or as the muted + // unavailable-reason pill. + locationIndexOk: boolean; // Subject coordinates for the HeroBar locator mini-map (Leaflet/OSM). null // when the estimate has no geocode yet — the map then renders an honest // "нет координат" placeholder instead of an empty/broken box. @@ -287,21 +295,11 @@ export interface DropdownOptions { crm: string[]; } -// ---- КОЭФ. ЛОКАЦИИ DRAWER ------------------------------------------------- - -export interface LocationFactor { - label: string; - delta: string; -} - -export interface LocationFactors { - coef: string; - intro: string; - formula: { base: string; coef: string; result: string }; - positives: LocationFactor[]; - negatives: LocationFactor[]; - footer: string; -} +// Note: the pre-wiring `LocationFactor`/`LocationFactors` types (base/coef/ +// result formula + positives/negatives) lived here — removed together with +// the location-index rewrite: LocationDrawer now consumes `LocationData` +// (see ./mappers.ts), which reflects the real backend contract, not the old +// fabricated formula. // ---- USER / CHROME -------------------------------------------------------- diff --git a/tradein-mvp/frontend/src/lib/trade-in-api.ts b/tradein-mvp/frontend/src/lib/trade-in-api.ts index fd00950f..b3982e11 100644 --- a/tradein-mvp/frontend/src/lib/trade-in-api.ts +++ b/tradein-mvp/frontend/src/lib/trade-in-api.ts @@ -12,7 +12,7 @@ import type { HouseAnalyticsResponse, HouseInfoForEstimate, IMVBenchmarkResponse, - LocationCoefResponse, + LocationIndexResponse, PlacementHistoryItem, SalesVsListingsResponse, SellTimeSensitivityResponse, @@ -147,22 +147,24 @@ export function useEstimateHouseAnalytics(estimate_id: string | null) { } /** - * GET /api/v1/trade-in/location-coef?estimate_id=&radius_m= - * POI-based location coefficient for the estimate's target address (#2045 BE-3 - * backend, #2317 FE wiring — LocationDrawer + HeroBar «КОЭФ. ЛОКАЦИИ»). coef is - * an MVP heuristic in [0.95, 1.05], NOT calibrated on real price deltas. - * geo_source="unavailable" is a legitimate graceful-fallback response (local POI - * mirror empty/stale on this environment, or the estimate has no lat/lon) — - * ./v2/mappers.ts renders it as an honest "—", never a fabricated coefficient. + * GET /api/v1/trade-in/location-index?estimate_id=&radius_m= + * Location index for the estimate's target address — replaces the broken + * location-coef (backend rewrite, see app/services/location_index.py): + * % deviation of the local median ₽/м² (comparable active listings near the + * address) from the citywide median ₽/м², NOT a price multiplier and NOT fed + * into the estimate. status="out_of_coverage"/"insufficient_data" are honest + * graceful-fallback responses (address outside Yekaterinburg / too few + * comparables) — ./v2/mappers.ts renders each distinctly, never a fabricated + * number. */ -export function useLocationCoef(estimate_id: string | null, radius_m?: number) { +export function useLocationIndex(estimate_id: string | null, radius_m?: number) { const params = new URLSearchParams(); if (estimate_id) params.set("estimate_id", estimate_id); if (radius_m != null) params.set("radius_m", String(radius_m)); - return useQuery({ - queryKey: ["trade-in", "location-coef", estimate_id, radius_m ?? null], + return useQuery({ + queryKey: ["trade-in", "location-index", estimate_id, radius_m ?? null], queryFn: () => - apiFetch(`${BASE}/location-coef?${params}`), + apiFetch(`${BASE}/location-index?${params}`), enabled: estimate_id !== null && estimate_id.length > 0, staleTime: 10 * 60_000, }); diff --git a/tradein-mvp/frontend/src/types/trade-in.ts b/tradein-mvp/frontend/src/types/trade-in.ts index 8e559418..296882dc 100644 --- a/tradein-mvp/frontend/src/types/trade-in.ts +++ b/tradein-mvp/frontend/src/types/trade-in.ts @@ -470,27 +470,41 @@ export interface TradeInLeadResponse { status: string; // 'received' } -// ── Location coefficient (endpoint: GET /trade-in/location-coef?estimate_id=&radius_m=) ── -// #2045 BE-3 (backend) / #2317 (this FE wiring) — LocationDrawer + HeroBar «КОЭФ. -// ЛОКАЦИИ». coef is an MVP heuristic (see backend app/services/location_coef.py -// ::_score_to_coef) — NOT calibrated on real price deltas, range [0.95, 1.05]. -// result_price_rub = round(base_price_rub * coef). +// ── Location index (endpoint: GET /trade-in/location-index?estimate_id=&radius_m=) ── +// Replaces the broken location-coef (#2045 audit — see +// backend/app/services/location_index.py for the full history: the old +// `coef = 0.95 + score/100*0.10` was clamped to ±5%, uncorrelated with real +// prices, and never actually fed the estimate). location_index_pct is the % +// deviation of the local median ₽/м² (comparable active listings near the +// address) from the citywide median ₽/м² — a real, uncalibrated-range +// comparison metric, NOT a price multiplier. It does NOT feed the estimate +// (analogs already carry location in the base price; folding this in again +// would double-count the same effect). // -// geo_source="unavailable" is a legitimate graceful-fallback response (the local -// osm_poi_ekb_local mirror is empty/stale on this environment, OR the estimate -// has no lat/lon) — coef=1.0 and factors=[] in that case, never fabricated. -// "osm_poi_ekb" is the normal/real-data source. -export interface LocationCoefFactor { +// status: +// "ok" — location_index_pct/local_median_price_per_m2 reliable. +// "out_of_coverage" — address outside the product's geo coverage +// (Yekaterinburg only). All numeric fields null — an honest dash, not 0%. +// "insufficient_data" — even at the widest search radius there are too few +// comparable active listings. Numeric fields null; sample_size/radius_m +// still report what was actually found. +// +// poi_status is independent of status above ("что рядом" and the numeric +// index degrade separately): "ok" | "unavailable" (local OSM POI mirror +// empty/not yet refreshed on this environment — never fabricated points). +export interface NearbyPoi { poi_type: string; // OSM POI category, e.g. "school" / "metro_stop" / "kindergarten" name: string | null; // POI name if known distance_m: number; - weight: number; // internal score contribution — NOT a per-POI price delta } -export interface LocationCoefResponse { - coef: number; - factors: LocationCoefFactor[]; - geo_source: string; // "osm_poi_ekb" | "unavailable" - base_price_rub: number; - result_price_rub: number; +export interface LocationIndexResponse { + status: "ok" | "out_of_coverage" | "insufficient_data"; + location_index_pct: number | null; + local_median_price_per_m2: number | null; + city_median_price_per_m2: number | null; + sample_size: number; + radius_m: number; + nearby_poi: NearbyPoi[]; + poi_status: "ok" | "unavailable"; }