diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index ac91142e..a86c84f6 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -1645,11 +1645,18 @@ async def estimate_quality( # больше не размывается массовой застройкой). За флагом; OFF ⇒ точно старое # поведение. listing_segment для Tier C берём из самого частого среди аналогов. anchor_tier: str | None = None + # #691: гейт НЕ требует радиусных аналогов (listings_clean) / median_price>0. + # На проде геокод часто = None → ST_DWithin не находит радиусные комплы → + # median=0, и same-building якорь скипался, ХОТЯ комплы того же дома есть + # (_fetch_anchor_comps резолвит дом по payload.address без гео — Ткачёва 13, + # Сакко 99). Запускаем по resolved-таргету (area + raw-адрес/geo); если комплов + # реально нет — _compute_same_building_anchor вернёт None и мутации не будет + # (поведение идентично старому). Новый гейт ⊇ старого: при непустом radius + # адрес всегда есть, так что ранее-якорившиеся кейсы якорятся по-прежнему. if ( settings.estimate_same_building_anchor_enabled - and listings_clean - and median_price > 0 and payload.area_m2 + and (payload.address or (geo is not None and geo.full_address)) ): seg_counts: dict[str, int] = {} for lot in listings_clean: diff --git a/tradein-mvp/backend/tests/test_same_building_anchor.py b/tradein-mvp/backend/tests/test_same_building_anchor.py index 1f1176f3..ed92620d 100644 --- a/tradein-mvp/backend/tests/test_same_building_anchor.py +++ b/tradein-mvp/backend/tests/test_same_building_anchor.py @@ -377,13 +377,20 @@ def _run_estimate( flag_enabled: bool = True, ratio_tuple: tuple[float | None, str | None] = (0.92, "per_rooms"), payload=None, + radius_analogs: list[dict[str, Any]] | None = None, ): - """estimate_quality со всеми I/O застабленными; _fetch_anchor_comps форсирован.""" + """estimate_quality со всеми I/O застабленными; _fetch_anchor_comps форсирован. + + radius_analogs=[] моделирует прод-сценарий #691: геокод дал lat/lon, но + ST_DWithin не нашёл радиусных аналогов (median=0) — якорь обязан сработать + по same-building комплам независимо. + """ from app.core.config import settings from app.services.estimator import estimate_quality db = MagicMock() pl = payload or _make_payload() + radius = list(_RADIUS_ANALOGS if radius_analogs is None else radius_analogs) async def _run(): with ( @@ -394,7 +401,7 @@ def _run_estimate( patch("app.services.estimator.get_house_metadata", new=AsyncMock(return_value=None)), patch( "app.services.estimator._fetch_analogs", - return_value=(list(_RADIUS_ANALOGS), False, "W"), + return_value=(radius, False, "W"), ), patch("app.services.estimator._fetch_deals", return_value=[]), patch("app.services.estimator._fetch_dkp_corridor", return_value=None), @@ -434,6 +441,30 @@ def test_estimate_premium_lift_real_in_range() -> None: assert est.range_high_rub >= int(real * 0.9) +def test_estimate_anchor_fires_without_radius_analogs() -> None: + """#691: радиусных аналогов НЕТ (median=0, прод-кейс провала ST_DWithin), но + same-building комплы есть → якорь обязан сработать и дать median>0. + До фикса гейт требовал listings_clean+median>0 → якорь скипался.""" + est = _run_estimate( + anchor_comps=_SB_COMPS_PREMIUM, + anchor_tier="A", + radius_analogs=[], + ) + # Якорь дал ненулевой headline несмотря на пустой радиус. + assert est.median_price_rub > 0 + assert est.median_price_per_m2 >= 450_000 + # range валиден (low ≤ point ≤ high), не вырожден в 0. + assert 0 < est.range_low_rub <= est.median_price_rub <= est.range_high_rub + + +def test_estimate_no_anchor_no_radius_stays_insufficient() -> None: + """#691 контроль: ни радиуса, ни same-building комплов → median остаётся 0 + (insufficient), фикс НЕ фабрикует число из воздуха.""" + est = _run_estimate(anchor_comps=[], anchor_tier=None, radius_analogs=[]) + assert est.median_price_rub == 0 + assert est.median_price_per_m2 == 0 + + def test_estimate_economy_no_regression() -> None: """(b) Эконом-комплы ~112k → guardrail не раздувает, headline ≈ комплов.""" eco_comps = [