fix(tradein): честный радиус поиска аналогов на карте v2 (#2632) #2643
6 changed files with 201 additions and 1 deletions
|
|
@ -261,6 +261,16 @@ class AggregatedEstimate(BaseModel):
|
||||||
# null — нет данных / оценка не построена
|
# null — нет данных / оценка не построена
|
||||||
# НЕ удаляет/заменяет confidence_explanation (фронт fallback'ает на него).
|
# НЕ удаляет/заменяет confidence_explanation (фронт fallback'ает на него).
|
||||||
analog_tier: Literal["same_building", "micro_radius", "district", "city"] | None = None
|
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: премиальный дом (флаг, НЕ ценовой сигнал) ──
|
# ── #2002: премиальный дом (флаг, НЕ ценовой сигнал) ──
|
||||||
# premium_building — целевой дом признан премиальным. Источник — curated overlay
|
# premium_building — целевой дом признан премиальным. Источник — curated overlay
|
||||||
# `premium_buildings_curated` (data/sql/142, AI/human-выверенный класс + false-
|
# `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
|
base_radius_m = payload.radius_m or DEFAULT_RADIUS_M
|
||||||
fallback_radius_m = payload.radius_m or FALLBACK_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)
|
cohort_range = _target_cohort_range(target_year)
|
||||||
|
|
||||||
if cohort_range is not None:
|
if cohort_range is not None:
|
||||||
|
|
@ -3566,6 +3573,7 @@ async def estimate_quality(
|
||||||
listings = listings_wide
|
listings = listings_wide
|
||||||
fallback_used = True
|
fallback_used = True
|
||||||
analog_tier = analog_tier_wide
|
analog_tier = analog_tier_wide
|
||||||
|
search_radius_m = fallback_radius_m
|
||||||
|
|
||||||
# Tier C: если даже на 2км мало — расширяем area tolerance до ±25%
|
# Tier C: если даже на 2км мало — расширяем area tolerance до ±25%
|
||||||
# (актуально для отдалённых районов / новостроек с нестандартной планировкой)
|
# (актуально для отдалённых районов / новостроек с нестандартной планировкой)
|
||||||
|
|
@ -3590,6 +3598,7 @@ async def estimate_quality(
|
||||||
fallback_used = True
|
fallback_used = True
|
||||||
area_widened = True
|
area_widened = True
|
||||||
analog_tier = analog_tier_wa
|
analog_tier = analog_tier_wa
|
||||||
|
search_radius_m = fallback_radius_m
|
||||||
|
|
||||||
# ── PRE-FETCH: dkp_raw (hoisted before _price_from_inputs) ──────────────
|
# ── PRE-FETCH: dkp_raw (hoisted before _price_from_inputs) ──────────────
|
||||||
# #1795: ДКП-коридор фетчим ДО вызова _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 []),
|
metro_nearest=(dadata.metro if dadata and dadata.metro else []),
|
||||||
address_precision=_qc_geo_to_precision(dadata.qc_geo if dadata else None),
|
address_precision=_qc_geo_to_precision(dadata.qc_geo if dadata else None),
|
||||||
analog_tier=api_analog_tier, # type: ignore[arg-type]
|
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=premium_building,
|
||||||
premium_building_median_ppm2=premium_building_median_ppm2,
|
premium_building_median_ppm2=premium_building_median_ppm2,
|
||||||
premium_building_class=premium_building_class,
|
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.
|
requires DATABASE_URL. Set it BEFORE importing app modules.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
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")
|
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||||
|
|
||||||
|
import anyio
|
||||||
import pytest
|
import pytest
|
||||||
from pydantic import ValidationError
|
from pydantic import ValidationError
|
||||||
|
|
||||||
from app.schemas.trade_in import TradeInEstimateInput
|
from app.schemas.trade_in import TradeInEstimateInput
|
||||||
from app.services import estimator
|
from app.services import estimator
|
||||||
|
from app.services.geocoder import GeocodeResult
|
||||||
|
|
||||||
|
|
||||||
def test_radius_m_defaults_to_none() -> None:
|
def test_radius_m_defaults_to_none() -> None:
|
||||||
|
|
@ -57,5 +64,104 @@ def test_api_radius_expand_clamp_bounds() -> None:
|
||||||
assert _clamp(9000) == 5000
|
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
|
if __name__ == "__main__": # pragma: no cover
|
||||||
raise SystemExit(pytest.main([__file__, "-q"]))
|
raise SystemExit(pytest.main([__file__, "-q"]))
|
||||||
|
|
|
||||||
|
|
@ -1043,6 +1043,9 @@ export default function TradeInV2Page() {
|
||||||
// #2576 — city_hint contract (backend PR #2580): honest
|
// #2576 — city_hint contract (backend PR #2580): honest
|
||||||
// heads-up when the geocoder picked the city itself.
|
// heads-up when the geocoder picked the city itself.
|
||||||
cityAmbiguous={estimate?.target_city_ambiguous ?? false}
|
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}
|
{middleContent}
|
||||||
{/* #2275: on mobile ObjectSummary is rendered fluid in the
|
{/* #2275: on mobile ObjectSummary is rendered fluid in the
|
||||||
|
|
|
||||||
|
|
@ -504,6 +504,27 @@ const cityAmbiguousText: CSSProperties = {
|
||||||
color: tokens.warn,
|
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
|
// РАДИУС dropdown panel — mirrors the <Dd> HUD panel (surface.w98 + soft blue
|
||||||
// shadow), sized to the narrow radius trigger and dropped just beneath it.
|
// shadow), sized to the narrow radius trigger and dropped just beneath it.
|
||||||
const radiusPanel: CSSProperties = {
|
const radiusPanel: CSSProperties = {
|
||||||
|
|
@ -546,6 +567,17 @@ interface ParamsPanelProps {
|
||||||
* correspond to real lat/lon at the map's actual zoom — projecting them
|
* 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. */
|
* would be a new, subtler version of the honesty bug this map replaces. */
|
||||||
markers?: MapMarker[];
|
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
|
// rooms number -> dropdown label. The design has no «Студия» option, so studio
|
||||||
|
|
@ -616,6 +648,7 @@ export default function ParamsPanel({
|
||||||
error = null,
|
error = null,
|
||||||
initialValues,
|
initialValues,
|
||||||
cityAmbiguous = false,
|
cityAmbiguous = false,
|
||||||
|
searchRadiusM = null,
|
||||||
// markers intentionally not destructured — see the ParamsPanelProps.markers
|
// markers intentionally not destructured — see the ParamsPanelProps.markers
|
||||||
// doc comment: its %-positions belong to the retired decorative SVG grid and
|
// doc comment: its %-positions belong to the retired decorative SVG grid and
|
||||||
// do not correspond to real lat/lon on the Leaflet map below.
|
// 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
|
// 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).
|
// (unlike the old SVG ring, which was a clamped pixel best-effort).
|
||||||
const parsedRadiusM = parseInt(radius, 10);
|
const parsedRadiusM = parseInt(radius, 10);
|
||||||
const circleRadiusM = Number.isFinite(parsedRadiusM)
|
const previewRadiusM = Number.isFinite(parsedRadiusM)
|
||||||
? parsedRadiusM
|
? parsedRadiusM
|
||||||
: AUTO_RADIUS_PREVIEW_M;
|
: 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
|
// 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.
|
// form, so the pin never borrows an analog's area. Empty area → no caption.
|
||||||
|
|
@ -1445,6 +1490,21 @@ export default function ParamsPanel({
|
||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* INPUTS */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|
|
||||||
|
|
@ -200,6 +200,13 @@ export interface AggregatedEstimate {
|
||||||
// ── Структурный уровень аналогов (feat/estimator-ui-transparency) ──
|
// ── Структурный уровень аналогов (feat/estimator-ui-transparency) ──
|
||||||
// Появляется после деплоя backend'а; до этого absent/null → UI graceful fallback.
|
// Появляется после деплоя backend'а; до этого absent/null → UI graceful fallback.
|
||||||
analog_tier?: AnalogTier | null;
|
analog_tier?: AnalogTier | null;
|
||||||
|
// #2632: фактический радиус (м), по которому backend реально отобрал
|
||||||
|
// listings-аналоги — может отличаться от радиуса, выбранного пользователем
|
||||||
|
// в дропдауне РАДИУС (сервер молча расширяет 1 км → 2 км при нехватке
|
||||||
|
// аналогов, см. confidence_explanation). Карта должна рисовать круг по
|
||||||
|
// ЭТОМУ полю, не по выбору пользователя. undefined/null — старая оценка
|
||||||
|
// (GET rehydrate) или поиск аналогов не выполнялся → UI fallback на выбор.
|
||||||
|
search_radius_m?: number | null;
|
||||||
// ── Параметры оценённой квартиры (для восстановления карточки по ?id=) ──
|
// ── Параметры оценённой квартиры (для восстановления карточки по ?id=) ──
|
||||||
area_m2: number | null;
|
area_m2: number | null;
|
||||||
rooms: number | null;
|
rooms: number | null;
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue