fix(tradein): честный радиус поиска аналогов на карте v2 (#2632)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI / changes (pull_request) Successful in 8s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m19s
CI Trade-In / backend-tests (pull_request) Successful in 2m51s

Эстиматор молча расширял радиус 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
This commit is contained in:
bot-backend 2026-08-05 11:09:47 +05:00
parent 7d13e93792
commit d132187155
6 changed files with 201 additions and 1 deletions

View file

@ -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-

View file

@ -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,

View file

@ -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"]))

View file

@ -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

View file

@ -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={{

View file

@ -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;