fix(tradein): честный радиус поиска аналогов на карте v2 (#2632) #2643
6 changed files with 201 additions and 1 deletions
|
|
@ -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-
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"]))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 <Dd> 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({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* #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 && (
|
||||
<div style={radiusExpandedText} role="status">
|
||||
Аналогов в радиусе {formatRadiusRu(previewRadiusM)} не хватило —
|
||||
поиск расширен до {formatRadiusRu(searchRadiusM)}.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* INPUTS */}
|
||||
<div
|
||||
style={{
|
||||
|
|
|
|||
|
|
@ -200,6 +200,13 @@ export interface AggregatedEstimate {
|
|||
// ── Структурный уровень аналогов (feat/estimator-ui-transparency) ──
|
||||
// Появляется после деплоя backend'а; до этого absent/null → UI graceful fallback.
|
||||
analog_tier?: AnalogTier | null;
|
||||
// #2632: фактический радиус (м), по которому backend реально отобрал
|
||||
// listings-аналоги — может отличаться от радиуса, выбранного пользователем
|
||||
// в дропдауне РАДИУС (сервер молча расширяет 1 км → 2 км при нехватке
|
||||
// аналогов, см. confidence_explanation). Карта должна рисовать круг по
|
||||
// ЭТОМУ полю, не по выбору пользователя. undefined/null — старая оценка
|
||||
// (GET rehydrate) или поиск аналогов не выполнялся → UI fallback на выбор.
|
||||
search_radius_m?: number | null;
|
||||
// ── Параметры оценённой квартиры (для восстановления карточки по ?id=) ──
|
||||
area_m2: number | null;
|
||||
rooms: number | null;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue