From d13218715576d8ce2ed23ee3f56b9c0fcb3b27f4 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Wed, 5 Aug 2026 11:09:47 +0500 Subject: [PATCH] =?UTF-8?q?fix(tradein):=20=D1=87=D0=B5=D1=81=D1=82=D0=BD?= =?UTF-8?q?=D1=8B=D0=B9=20=D1=80=D0=B0=D0=B4=D0=B8=D1=83=D1=81=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=B8=D1=81=D0=BA=D0=B0=20=D0=B0=D0=BD=D0=B0=D0=BB=D0=BE?= =?UTF-8?q?=D0=B3=D0=BE=D0=B2=20=D0=BD=D0=B0=20=D0=BA=D0=B0=D1=80=D1=82?= =?UTF-8?q?=D0=B5=20v2=20(#2632)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Эстиматор молча расширял радиус 1км→2км при нехватке аналогов, но наружу фактический радиус не отдавал — круг на карте рисовался по выбору пользователя, текст говорил одно, картинка другое (Первоуральск воспроизводился). search_radius_m в AggregatedEstimate: init=base, флип на fallback ровно в тех же ветках, что ставят fallback_used (текст и число структурно не могут разойтись); ДКП-коридор и anchor-пути не влияют (поле = радиус отбора listings-аналогов). フронт: круг по search_radius_m (пока дропдаун не тронут после ремаунта по estimate_id), фолбэк на выбор пользователя при отсутствии поля; disclosure «Аналогов в радиусе 1 км не хватило — поиск расширен до 2 км» в стиле оговорок v2. Не персистится (конвенция analog_tier) — rehydrate отдаёт None без мигания. Refs #2632 --- tradein-mvp/backend/app/schemas/trade_in.py | 10 ++ tradein-mvp/backend/app/services/estimator.py | 14 +++ .../tests/test_estimator_radius_m_2044.py | 106 ++++++++++++++++++ tradein-mvp/frontend/src/app/v2/page.tsx | 3 + .../components/trade-in/v2/ParamsPanel.tsx | 62 +++++++++- tradein-mvp/frontend/src/types/trade-in.ts | 7 ++ 6 files changed, 201 insertions(+), 1 deletion(-) diff --git a/tradein-mvp/backend/app/schemas/trade_in.py b/tradein-mvp/backend/app/schemas/trade_in.py index c2e42560..3df09fe1 100644 --- a/tradein-mvp/backend/app/schemas/trade_in.py +++ b/tradein-mvp/backend/app/schemas/trade_in.py @@ -261,6 +261,16 @@ class AggregatedEstimate(BaseModel): # null — нет данных / оценка не построена # НЕ удаляет/заменяет confidence_explanation (фронт fallback'ает на него). analog_tier: Literal["same_building", "micro_radius", "district", "city"] | None = None + # search_radius_m — фактический радиус (метры), по которому реально отбирались + # listings-аналоги (estimator.py: base_radius_m/fallback_radius_m, #2632). Может + # ОТЛИЧАТЬСЯ от TradeInEstimateInput.radius_m (выбор пользователя в дропдауне): + # сервер молча расширяет 1 км → 2 км при нехватке аналогов (см. + # confidence_explanation "расширили радиус до 2 км"). Фронт рисует круг на карте + # по ЭТОМУ полю (не по своему выбору) — иначе карта врёт о реально + # использованном радиусе. None на GET-rehydrate (не персистится, старые записи) + # и у _empty_estimate (поиск аналогов не выполнялся) — фронт в этом случае + # fallback'ает на выбор пользователя. + search_radius_m: int | None = None # ── #2002: премиальный дом (флаг, НЕ ценовой сигнал) ── # premium_building — целевой дом признан премиальным. Источник — curated overlay # `premium_buildings_curated` (data/sql/142, AI/human-выверенный класс + false- diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index 8981affb..388216c8 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -3502,6 +3502,13 @@ async def estimate_quality( # радиус (он же — максимум, без авто-расширения за пределы выбранного). base_radius_m = payload.radius_m or DEFAULT_RADIUS_M fallback_radius_m = payload.radius_m or FALLBACK_RADIUS_M + # #2632: фактический радиус, по которому реально отобраны listings-аналоги + # (в отличие от payload.radius_m — выбор пользователя в дропдауне). Стартует + # с base_radius_m, переключается на fallback_radius_m в тех же ветках, что + # выставляют fallback_used ниже (см. _compute_confidence "расширили радиус" + # note) — держим оба сигнала консистентными по построению. Прокидывается в + # AggregatedEstimate.search_radius_m для карты (ParamsPanel circle, #2632). + search_radius_m = base_radius_m cohort_range = _target_cohort_range(target_year) if cohort_range is not None: @@ -3566,6 +3573,7 @@ async def estimate_quality( listings = listings_wide fallback_used = True analog_tier = analog_tier_wide + search_radius_m = fallback_radius_m # Tier C: если даже на 2км мало — расширяем area tolerance до ±25% # (актуально для отдалённых районов / новостроек с нестандартной планировкой) @@ -3590,6 +3598,7 @@ async def estimate_quality( fallback_used = True area_widened = True analog_tier = analog_tier_wa + search_radius_m = fallback_radius_m # ── PRE-FETCH: dkp_raw (hoisted before _price_from_inputs) ────────────── # #1795: ДКП-коридор фетчим ДО вызова _price_from_inputs, чтобы @@ -4149,6 +4158,11 @@ async def estimate_quality( metro_nearest=(dadata.metro if dadata and dadata.metro else []), address_precision=_qc_geo_to_precision(dadata.qc_geo if dadata else None), analog_tier=api_analog_tier, # type: ignore[arg-type] + # #2632: фактический радиус отбора listings-аналогов (см. search_radius_m + # def выше) — может отличаться от payload.radius_m (выбор пользователя), + # когда сервер сам расширил поиск. None только у _empty_estimate (поиск + # аналогов вообще не выполнялся). + search_radius_m=search_radius_m, premium_building=premium_building, premium_building_median_ppm2=premium_building_median_ppm2, premium_building_class=premium_building_class, diff --git a/tradein-mvp/backend/tests/test_estimator_radius_m_2044.py b/tradein-mvp/backend/tests/test_estimator_radius_m_2044.py index f7444f65..ffc31b5c 100644 --- a/tradein-mvp/backend/tests/test_estimator_radius_m_2044.py +++ b/tradein-mvp/backend/tests/test_estimator_radius_m_2044.py @@ -9,15 +9,22 @@ NOTE: importing app.services.estimator pulls app.core.config.Settings which requires DATABASE_URL. Set it BEFORE importing app modules. """ +from __future__ import annotations + import os +from datetime import UTC, datetime +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") +import anyio import pytest from pydantic import ValidationError from app.schemas.trade_in import TradeInEstimateInput from app.services import estimator +from app.services.geocoder import GeocodeResult def test_radius_m_defaults_to_none() -> None: @@ -57,5 +64,104 @@ def test_api_radius_expand_clamp_bounds() -> None: assert _clamp(9000) == 5000 +# ───────────────────────────────────────────────────────────────────────────── +# #2632 — AggregatedEstimate.search_radius_m: the ACTUAL radius listings were +# selected at (base vs. server-side fallback expansion), independent of what +# the caller/UI asked for. Pattern mirrors the full stub-patched estimate_quality +# harness in test_estimator_headline_sufficiency.py. +# ───────────────────────────────────────────────────────────────────────────── + + +def _geo() -> GeocodeResult: + return GeocodeResult( + lat=56.909, lon=59.960, full_address="Первоуральск, ул. Ленина, 5", provider="nominatim" + ) + + +def _make_listing(*, price_per_m2: float, address: str) -> dict[str, Any]: + return { + "source": "avito", + "source_url": f"https://avito.ru/offer/{address}", + "address": address, + "lat": 56.909, + "lon": 59.960, + "rooms": 2, + "area_m2": 45.0, + "floor": 5, + "total_floors": 9, + "price_rub": price_per_m2 * 45.0, + "price_per_m2": price_per_m2, + "listing_date": datetime(2026, 5, 1), + "days_on_market": 10, + "photo_urls": [], + "scraped_at": datetime(2026, 5, 20, tzinfo=UTC), + "distance_m": 150.0, + "relevance_score": 0.1, + } + + +def _run_estimate_with_radius_tiers(*, base_n: int, fallback_n: int) -> Any: + """estimate_quality() with _fetch_analogs stubbed per-radius: returns + `base_n` listings at DEFAULT_RADIUS_M and `fallback_n` at FALLBACK_RADIUS_M — + lets a single test control whether the server-side widen branch fires.""" + from app.services.estimator import estimate_quality + + db = MagicMock() + payload = TradeInEstimateInput( + address="Первоуральск, ул. Ленина, 5", area_m2=45.0, rooms=2, floor=5, total_floors=9 + ) + + def _fetch_analogs_stub(*_args: Any, **kwargs: Any) -> tuple[list[dict[str, Any]], bool, str]: + radius = kwargs.get("radius_m") + n = fallback_n if radius == estimator.FALLBACK_RADIUS_M else base_n + listings = [ + _make_listing(price_per_m2=200_000.0 + i * 1_000, address=f"д. {i}") for i in range(n) + ] + return listings, radius == estimator.FALLBACK_RADIUS_M, "W" + + async def _run() -> Any: + with ( + patch("app.services.estimator.geocode", new=AsyncMock(return_value=_geo())), + patch("app.services.estimator.dadata_clean_address", new=AsyncMock(return_value=None)), + patch("app.services.estimator.match_house_readonly", return_value=None), + patch("app.services.estimator.get_house_metadata", new=AsyncMock(return_value=None)), + patch("app.services.estimator._fetch_analogs", side_effect=_fetch_analogs_stub), + patch("app.services.estimator._fetch_anchor_comps", return_value=([], None)), + patch("app.services.estimator._fetch_deals", return_value=[]), + patch( + "app.services.estimator._get_or_fetch_imv_cached", + new=AsyncMock(return_value=None), + ), + patch( + "app.services.estimator._get_or_fetch_yandex_valuation_cached", + new=AsyncMock(return_value=None), + ), + patch( + "app.services.estimator.estimate_via_cian_valuation", + new=AsyncMock(return_value=None), + ), + patch("app.services.estimator._fetch_dkp_corridor", return_value=None), + patch("app.services.estimator._get_asking_sold_ratio", return_value=(None, None)), + ): + return await estimate_quality(payload, db) + + return anyio.run(_run) + + +def test_search_radius_m_reports_base_when_no_expansion_needed() -> None: + """Enough analogs already at the 1 km base radius → no fallback widen fires, + search_radius_m must report the base radius (1000 m), not just be absent.""" + est = _run_estimate_with_radius_tiers(base_n=5, fallback_n=20) + assert est.search_radius_m == estimator.DEFAULT_RADIUS_M + + +def test_search_radius_m_reports_fallback_when_server_expands() -> None: + """#2632 repro: base radius (1 km) starves (<5 listings) → server silently + widens to the 2 km fallback. The circle on the map must follow THIS radius, + not the user's original dropdown choice — search_radius_m must say 2000.""" + est = _run_estimate_with_radius_tiers(base_n=2, fallback_n=8) + assert est.search_radius_m == estimator.FALLBACK_RADIUS_M + + if __name__ == "__main__": # pragma: no cover raise SystemExit(pytest.main([__file__, "-q"])) diff --git a/tradein-mvp/frontend/src/app/v2/page.tsx b/tradein-mvp/frontend/src/app/v2/page.tsx index e398c129..1476a7f7 100644 --- a/tradein-mvp/frontend/src/app/v2/page.tsx +++ b/tradein-mvp/frontend/src/app/v2/page.tsx @@ -1043,6 +1043,9 @@ export default function TradeInV2Page() { // #2576 — city_hint contract (backend PR #2580): honest // heads-up when the geocoder picked the city itself. cityAmbiguous={estimate?.target_city_ambiguous ?? false} + // #2632 — actual radius the backend searched analogs at + // (may exceed the dropdown's own default preview). + searchRadiusM={estimate?.search_radius_m ?? null} /> {middleContent} {/* #2275: on mobile ObjectSummary is rendered fluid in the diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/ParamsPanel.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/ParamsPanel.tsx index efbb1b68..91b8dc93 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/ParamsPanel.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/v2/ParamsPanel.tsx @@ -504,6 +504,27 @@ const cityAmbiguousText: CSSProperties = { color: tokens.warn, }; +// #2632 — same honest/calm tone as cityAmbiguousText above: the backend +// silently widened the search radius (1 км дефолт → 2 км fallback) because +// there weren't enough analogs nearby. Disclosure, not an error — the circle +// on the map already moved to match; this caption says WHY in words, right +// next to it (backend's own confidence_explanation carries the same fact, +// but lives in a separate panel that may be scrolled out of view). +const radiusExpandedText: CSSProperties = { + marginTop: 8, + fontSize: 10.5, + letterSpacing: 0.3, + lineHeight: 1.4, + color: tokens.warn, +}; + +// 1000/2000 м → "1 км"/"2 км" (matches how people actually talk about the +// РАДИУС options); anything not a whole km (e.g. a 300/500 m override) stays +// in metres. +function formatRadiusRu(m: number): string { + return m % 1000 === 0 ? `${m / 1000} км` : `${m} м`; +} + // РАДИУС dropdown panel — mirrors the
HUD panel (surface.w98 + soft blue // shadow), sized to the narrow radius trigger and dropped just beneath it. const radiusPanel: CSSProperties = { @@ -546,6 +567,17 @@ interface ParamsPanelProps { * correspond to real lat/lon at the map's actual zoom — projecting them * would be a new, subtler version of the honesty bug this map replaces. */ markers?: MapMarker[]; + /** + * #2632: фактический радиус (м), по которому backend реально отобрал + * listings-аналоги для ПОСЛЕДНЕЙ оценки (AggregatedEstimate.search_radius_m) + * — может отличаться от выбора пользователя в дропдауне РАДИУС, если сервер + * молча расширил поиск (1 км → 2 км) при нехватке аналогов. Пока пользователь + * не тронул дропдаун в текущей сессии панели — карта и disclosure-подсказка + * ориентируются на это значение, а не на дефолтный превью-радиус. null/undefined + * (нет оценки ещё / старая оценка без поля) → полный fallback на выбор + * пользователя, как раньше. + */ + searchRadiusM?: number | null; } // rooms number -> dropdown label. The design has no «Студия» option, so studio @@ -616,6 +648,7 @@ export default function ParamsPanel({ error = null, initialValues, cityAmbiguous = false, + searchRadiusM = null, // markers intentionally not destructured — see the ParamsPanelProps.markers // doc comment: its %-positions belong to the retired decorative SVG grid and // do not correspond to real lat/lon on the Leaflet map below. @@ -944,9 +977,21 @@ export default function ParamsPanel({ // L.circle takes a radius in metres, so this is a true geographic scale // (unlike the old SVG ring, which was a clamped pixel best-effort). const parsedRadiusM = parseInt(radius, 10); - const circleRadiusM = Number.isFinite(parsedRadiusM) + const previewRadiusM = Number.isFinite(parsedRadiusM) ? parsedRadiusM : AUTO_RADIUS_PREVIEW_M; + // #2632: "Авто" is both the untouched default AND a valid explicit choice + // ("let the backend decide") — in either case, once a real estimate exists, + // the honest circle is the radius the backend ACTUALLY searched at + // (searchRadiusM), not our pre-submit 1 km preview guess. The moment the + // user picks a concrete radius option in this mounted session, their live + // pick wins again (preview for a not-yet-submitted "what if" change) — + // exactly the old behaviour, just no longer overridden by a stale result. + const radiusTouchedSinceMount = radius !== "Авто"; + const circleRadiusM = + !radiusTouchedSinceMount && searchRadiusM != null + ? searchRadiusM + : previewRadiusM; // Subject area caption for the map pin (M10) — the SUBJECT's own m², from the // form, so the pin never borrows an analog's area. Empty area → no caption. @@ -1445,6 +1490,21 @@ export default function ParamsPanel({ + {/* #2632 — honest disclosure when the circle above is NOT the radius + the dropdown says: the backend widened the search itself. Only fires + while the dropdown is still untouched in this session (see + circleRadiusM/radiusTouchedSinceMount above) — once the user picks + their own radius to preview, that choice is uncontested and there is + nothing to disclose yet. */} + {!radiusTouchedSinceMount && + searchRadiusM != null && + searchRadiusM !== previewRadiusM && ( +
+ Аналогов в радиусе {formatRadiusRu(previewRadiusM)} не хватило — + поиск расширен до {formatRadiusRu(searchRadiusM)}. +
+ )} + {/* INPUTS */}