fix(tradein/estimate): отдавать фактический радиус подбора, а не выбор пользователя
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 10s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
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 / backend-tests (pull_request) Successful in 4m2s

Сохранённая оценка отдавала search_radius_m=null, и карта рисовала круг по
превью-радиусу 1 км. Прод 2026-08-11 (оценка 4c20518f, Советская 51):
relaxations = ["радиус расширен до 5000 м"], все 10 показанных аналогов вне
круга, самый дальний — 4381 м. По всей базе: у 62 из 388 оценок с расстояниями
(16%) есть аналог дальше километра, p90 максимума 1572 м, максимум 4381 м.

- GET /estimate/{id} восстанавливает фактический радиус из того, что реально
  персистится: подпись каскада «радиус расширен до N м» (точное значение, то же,
  что вернул POST), иначе — размах сохранённых аналогов, но не ниже
  DEFAULT_RADIUS_M. Нет расстояний — остаётся null, фронт падает на выбор
  пользователя, как раньше. Колонки/миграции не требуется.
- Подпись каскада и её разбор — один helper (radius_relaxation_label /
  radius_from_relaxations), иначе строка и парсер разъедутся.
- confidence_explanation называет ФАКТИЧЕСКИЙ радиус: было прибито «расширили
  радиус до 2 км» (константа старого 1км→2км fallback'а) даже когда каскад
  #oblast-F уходил до 5 км — метка называла не результат (ср. #2811).
- Ответ несёт обе величины: requested_radius_m (с чего начали) рядом с
  search_radius_m (чем кончили). На GET requested_radius_m остаётся null —
  radius_m не персистится, подставлять дефолт значило бы выдать догадку за
  выбор пользователя.

Refs #2632
This commit is contained in:
bot-backend 2026-08-12 17:42:49 +05:00
parent 4d31a0ee82
commit dae3f5bc41
6 changed files with 368 additions and 15 deletions

View file

@ -556,11 +556,22 @@ def get_estimate(
_qc_geo_to_precision,
_resolve_target_city,
_source_counts,
rehydrate_search_radius_m,
)
analogs = [AnalogLot(**a) for a in (row.analogs or [])]
actual_deals = [AnalogLot(**a) for a in (row.actual_deals or [])]
# #2632: search_radius_m колонкой не персистится — восстанавливаем его из
# того, что персистится (подпись каскада «радиус расширен до N м», иначе
# размах сохранённых аналогов). Без этого GET отдавал null, фронт падал на
# превью-радиус 1 км и рисовал круг, за которым лежат его же пины (прод
# 2026-08-11: 10 из 10 аналогов вне круга, самый дальний — 4381 м).
persisted_relaxations = list(getattr(row, "relaxations", None) or [])
search_radius_m = rehydrate_search_radius_m(
persisted_relaxations, [a.distance_m for a in analogs]
)
# #2043 (BE-1): CV / счётчики источников на rehydrate — best-effort из
# сохранённых analogs (top-N, усечённо: полная выборка не персистится). На
# свежей оценке (POST) считаются по полной выборке; здесь — по тому, что есть
@ -689,8 +700,12 @@ def get_estimate(
# getattr defensive: старые in-memory test doubles / любая строка без
# этих колонок (не должно случаться после миграции) деградируют в
# дефолт схемы (ok / []), а не падают AttributeError.
relaxations=list(getattr(row, "relaxations", None) or []),
relaxations=persisted_relaxations,
reliability=getattr(row, "reliability", None) or "ok",
# #2632: фактический радиус подбора (реконструкция выше). requested_radius_m
# осознанно НЕ заполняем — payload.radius_m не персистится, и подставить
# сюда дефолт значило бы выдать догадку за то, что просил пользователь.
search_radius_m=search_radius_m,
)

View file

@ -275,15 +275,26 @@ class AggregatedEstimate(BaseModel):
# НЕ удаляет/заменяет 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'ает на выбор пользователя.
# listings-аналоги (estimator.py, #2632). Может ОТЛИЧАТЬСЯ от requested_radius_m:
# при нехватке аналогов сервер расширяет поиск сам (1 км → 2 км, дальше каскад
# #oblast-F до 3/5 км — только когда пользователь НЕ зафиксировал радиус явно,
# контракт #2044). Фронт рисует круг на карте по ЭТОМУ полю (не по своему
# выбору) — иначе карта врёт о реально использованном радиусе.
# На GET-rehydrate колонки под него нет, поэтому значение ВОССТАНАВЛИВАЕТСЯ
# (estimator.rehydrate_search_radius_m): из persisted-подписи каскада
# «радиус расширен до N м» (точное значение, строки с 2026-08-10), иначе из
# размаха сохранённых аналогов, но не меньше DEFAULT_RADIUS_M. None — у
# _empty_estimate (поиск не выполнялся) и у старых строк без расстояний;
# фронт тогда fallback'ает на выбор пользователя, как раньше.
search_radius_m: int | None = None
# requested_radius_m — радиус, с которого поиск НАЧАЛСЯ: явный выбор
# пользователя (TradeInEstimateInput.radius_m) либо DEFAULT_RADIUS_M, если он
# выбрал «Авто». Отдаётся рядом с фактическим, чтобы ответ нёс ОБЕ величины —
# что просили и что получилось — и потребителю не приходилось выводить
# расхождение из своего локального состояния. None на GET-rehydrate:
# radius_m не персистится, а угадывать «просили 1 км» за пользователя —
# ровно та подмена входа результатом, которую чинит это поле.
requested_radius_m: int | None = None
# ── #2002: премиальный дом (флаг, НЕ ценовой сигнал) ──
# premium_building — целевой дом признан премиальным. Источник — curated overlay
# `premium_buildings_curated` (data/sql/142, AI/human-выверенный класс + false-

View file

@ -203,6 +203,57 @@ LISTINGS_FRESH_DAYS_RELAXED = 60 # #oblast-F (b): LISTINGS_FRESH_DAYS 14 → 60
RELAX_RADIUS_STEP1_M = 3000 # #oblast-F (d.1): max(текущий search_radius_m, 3000)
RELAX_RADIUS_STEP2_M = 5000 # #oblast-F (d.2): финальный максимум
# #2632: подпись радиусного ослабления — ЕДИНСТВЕННОЕ место, где число
# фактического радиуса попадает в persisted-состояние (колонка relaxations).
# Отсюда же его читает GET-rehydrate (radius_from_relaxations) — иначе строка и
# парсер разъедутся, и карта опять начнёт рисовать не тот круг.
RADIUS_RELAXATION_PREFIX = "радиус расширен до "
def radius_relaxation_label(radius_m: int) -> str:
"""RU-подпись шага (d) каскада #oblast-F для AggregatedEstimate.relaxations."""
return f"{RADIUS_RELAXATION_PREFIX}{radius_m} м"
def radius_from_relaxations(relaxations: list[str] | None) -> int | None:
"""Обратный разбор подписи: фактический радиус (м) или None, если его нет.
Нужен на GET-rehydrate: сам search_radius_m колонкой не персистится, но
подпись каскада да (миграция 255), и в ней лежит ровно то число, по
которому реально отбирались аналоги.
"""
for label in relaxations or []:
if label.startswith(RADIUS_RELAXATION_PREFIX):
digits = label[len(RADIUS_RELAXATION_PREFIX) :].removesuffix(" м").strip()
if digits.isdigit():
return int(digits)
return None
def rehydrate_search_radius_m(
relaxations: list[str] | None, analog_distances_m: list[int | None]
) -> int | None:
"""Фактический радиус подбора для СОХРАНЁННОЙ оценки (#2632).
Колонки под search_radius_m нет, поэтому восстанавливаем из того, что
персистится:
1) подпись каскада «радиус расширен до N м» точное значение, то же,
что вернул POST (строки, созданные с 2026-08-10, миграция 255);
2) иначе размах сохранённых аналогов, но не меньше DEFAULT_RADIUS_M:
круг обязан вмещать пины, которые сам же и показывает (тот же приём
уже применён во v2 SourcesMap там радиус круга = max distance_m).
None расстояний нет вовсе (старые/anchor-строки): вызывающий честно
падает обратно на выбор пользователя, как и до этой правки.
"""
labelled = radius_from_relaxations(relaxations)
if labelled is not None:
return labelled
known = [d for d in analog_distances_m if d is not None]
if not known:
return None
return max(DEFAULT_RADIUS_M, max(known))
# #794: СберИндекс time-adjustment of frozen Rosreestr ДКП deals.
# Rosreestr deals freeze ~2026-01; the sber monthly index re-bases a stale deal's ppm²
# to the latest available month. Region fixed to Свердловская обл. (tradein MVP = ЕКБ).
@ -2630,6 +2681,10 @@ def _price_from_inputs(
dadata_coarse: bool,
geo: GeocodeResult,
dadata_qc_geo: int | None,
# #2632: фактический радиус подбора (после каскада #oblast-F) — чтобы
# объяснение называло тот радиус, по которому реально искали. None →
# прежнее поведение (FALLBACK_RADIUS_M) для оффлайн-вызывающих (бэктест).
search_radius_m: int | None = None,
) -> PricingResult:
"""Deterministic pricing orchestration — pure, synchronous, zero I/O.
@ -2795,6 +2850,7 @@ def _price_from_inputs(
fallback_used,
area_widened,
listings=listings_clean,
radius_used_m=search_radius_m,
)
# #oblast-E/#oblast-F: honest low-reliability note — ONLY for case (ii) of
# the gate above (real thin median kept, no usable deals corridor to cede
@ -4000,7 +4056,7 @@ async def estimate_quality(
if area_widened:
relaxations.append("площадь ±25%")
if search_radius_m > base_radius_m:
relaxations.append(f"радиус расширен до {search_radius_m} м")
relaxations.append(radius_relaxation_label(search_radius_m))
# ── PRE-FETCH: dkp_raw (hoisted before _price_from_inputs) ──────────────
# #1795: ДКП-коридор фетчим ДО вызова _price_from_inputs, чтобы
@ -4192,6 +4248,7 @@ async def estimate_quality(
dadata_coarse=dadata_coarse,
geo=geo,
dadata_qc_geo=dadata.qc_geo if dadata else None,
search_radius_m=search_radius_m,
)
# Unpack pricing result
@ -4631,10 +4688,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 (поиск
# аналогов вообще не выполнялся).
# #2632: обе величины рядом — с чего поиск начали (выбор пользователя или
# DEFAULT_RADIUS_M) и на чём закончили. Расходятся, когда сервер сам
# расширил поиск; подменять одно другим нельзя — это ровно тот дефект
# метки, что чинили в #2811. None у _empty_estimate (поиск не выполнялся).
requested_radius_m=base_radius_m,
search_radius_m=search_radius_m,
premium_building=premium_building,
premium_building_median_ppm2=premium_building_median_ppm2,
@ -6668,6 +6726,11 @@ def _cap_confidence_by_reliability(confidence: str, reliability: str) -> str:
return confidence
def _radius_ru(radius_m: int) -> str:
"""«2 км» / «5 км» / «1500 м» — целые километры словом, остальное метрами."""
return f"{radius_m // 1000} км" if radius_m % 1000 == 0 else f"{radius_m} м"
def _compute_confidence(
n_analogs: int,
median_ppm2: float,
@ -6676,6 +6739,7 @@ def _compute_confidence(
fallback_radius_used: bool,
area_widened: bool = False,
listings: list[dict] | None = None,
radius_used_m: int | None = None,
) -> tuple[str, str]:
"""Confidence + explanation string.
@ -6709,7 +6773,12 @@ def _compute_confidence(
iqr_pct = iqr / median_ppm2 if median_ppm2 > 0 else 1.0
notes = []
if fallback_radius_used:
notes.append("расширили радиус до 2 км")
# #2632: НАЗЫВАЕМ фактический радиус. До этого здесь было прибито «2 км»
# — константа старого 1км→2км fallback'а, а каскад #oblast-F уходит до
# 3/5 км: текст утверждал 2 км там, где поиск реально шёл по 5 км
# (прод, 2026-08-11: relaxations «радиус расширен до 5000 м», самый
# дальний аналог 4381 м, объяснение — «расширили радиус до 2 км»).
notes.append(f"расширили радиус до {_radius_ru(radius_used_m or FALLBACK_RADIUS_M)}")
if area_widened:
notes.append("расширили допуск по площади до ±25%")
fallback_note = f" ({', '.join(notes)} из-за нехватки данных)" if notes else ""

View file

@ -163,6 +163,10 @@ def _stub_precision_and_pdf():
# #2087 (M1): GET-rehydrate derives canonical sources_used via the shared
# helper. Empty analogs + no valuation flags → [] (real behaviour).
_canonical_sources=lambda *a, **k: [],
# #2632: GET-rehydrate реконструирует фактический радиус подбора. У этих
# фикстур нет ни подписи каскада, ни расстояний → None и есть настоящее
# поведение (см. tests/test_estimator_search_radius_2632.py).
rehydrate_search_radius_m=lambda *a, **k: None,
)
real_estimator = sys.modules.get("app.services.estimator")
sys.modules["app.services.estimator"] = estimator_stub # type: ignore[assignment]

View file

@ -273,6 +273,10 @@ def _estimator_stub():
_cv_from_ppm2=lambda *a, **k: None,
_source_counts=lambda *a, **k: {},
_canonical_sources=lambda *a, **k: [],
# #2632: GET-rehydrate реконструирует фактический радиус подбора. У строк
# этой фикстуры analogs пусты → None и есть настоящее поведение
# (см. tests/test_estimator_search_radius_2632.py).
rehydrate_search_radius_m=lambda *a, **k: None,
estimate_quality=_default_estimate_quality,
)
sys.modules["app.services.estimator"] = stub # type: ignore[assignment]

View file

@ -0,0 +1,250 @@
"""#2632 — ответ несёт ФАКТИЧЕСКИЙ радиус подбора, а не выбор пользователя.
Прод, 2026-08-11 (оценка 4c20518f, ул. Советская 51): сохранённая строка несёт
`relaxations = ["радиус расширен до 5000 м"]`, все 10 сохранённых аналогов лежат
дальше километра (самый дальний 4381 м), а `GET /estimate/{id}` отдавал
`search_radius_m: null` фронт падал на превью-радиус 1 км и рисовал круг, за
которым остаются его же пины. Там же вторая ложь: `confidence_explanation`
утверждал «расширили радиус до 2 км» константа старого 1км2км fallback'а,
хотя каскад #oblast-F ушёл до 5 км.
Тесты падают на коде до этой правки: helper'ов не существует (ImportError),
а объяснение прибито к «2 км».
"""
from __future__ import annotations
import os
import sys
from datetime import UTC, datetime, timedelta
from types import SimpleNamespace
from unittest.mock import MagicMock
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
_wp_mock = MagicMock()
sys.modules.setdefault("weasyprint", _wp_mock)
sys.modules.setdefault("weasyprint.CSS", _wp_mock)
sys.modules.setdefault("weasyprint.HTML", _wp_mock)
import pytest # noqa: E402
from fastapi import FastAPI # noqa: E402
from fastapi.testclient import TestClient # noqa: E402
from app.services.estimator import ( # noqa: E402
DEFAULT_RADIUS_M,
_compute_confidence,
radius_from_relaxations,
radius_relaxation_label,
rehydrate_search_radius_m,
)
_ESTIMATE_ID = "4c20518f-7d5d-4f9a-9dde-8f65d5ea2202"
# ── подпись каскада ↔ разбор: единственный носитель числа в persisted-состоянии ──
def test_radius_label_roundtrips() -> None:
assert radius_from_relaxations([radius_relaxation_label(5000)]) == 5000
assert radius_from_relaxations(["учтены студии", radius_relaxation_label(2000)]) == 2000
# прод-строка дословно
assert radius_from_relaxations(["радиус расширен до 5000 м"]) == 5000
def test_radius_label_absent_or_malformed_gives_none() -> None:
assert radius_from_relaxations(None) is None
assert radius_from_relaxations([]) is None
assert radius_from_relaxations(["учтены новостройки", "площадь ±25%"]) is None
assert radius_from_relaxations(["радиус расширен до пяти км"]) is None
# ── реконструкция радиуса для сохранённой оценки ──────────────────────────────
def test_rehydrate_prefers_persisted_label_over_analog_extent() -> None:
"""Подпись — точное значение (то же, что вернул POST); размах — лишь оценка снизу."""
assert rehydrate_search_radius_m(["радиус расширен до 5000 м"], [4381, 1200, None]) == 5000
def test_rehydrate_falls_back_to_analog_extent() -> None:
"""Строки до миграции 255: подписи нет, но круг обязан вмещать свои же пины."""
assert rehydrate_search_radius_m([], [800, 2428, None]) == 2428
def test_rehydrate_never_below_default_radius() -> None:
"""Все аналоги рядом → поиск всё равно шёл по базовому радиусу, не по 300 м."""
assert rehydrate_search_radius_m([], [120, 300]) == DEFAULT_RADIUS_M
def test_rehydrate_without_distances_stays_none() -> None:
"""Нечего утверждать — фронт честно падает на выбор пользователя (как раньше)."""
assert rehydrate_search_radius_m([], []) is None
assert rehydrate_search_radius_m(None, [None, None]) is None
# ── объяснение называет тот радиус, по которому реально искали ────────────────
def _confidence(radius_used_m: int | None) -> str:
_, explanation = _compute_confidence(
12,
100_000.0,
95_000.0,
105_000.0,
True, # fallback_radius_used
False,
listings=[{"address": f"дом {i}"} for i in range(12)],
radius_used_m=radius_used_m,
)
return explanation
def test_explanation_names_actual_radius_not_hardcoded_2km() -> None:
text = _confidence(5000)
assert "5 км" in text
assert "2 км" not in text
def test_explanation_keeps_2km_wording_when_that_is_the_truth() -> None:
assert "2 км" in _confidence(2000)
# не-круглый радиус не притворяется километрами
assert "1500 м" in _confidence(1500)
# ── GET /estimate/{id}: сохранённая оценка отдаёт фактический радиус ──────────
def _prod_shaped_row() -> SimpleNamespace:
"""Форма прод-строки 4c20518f (расширение до 5 км, аналоги дальше километра)."""
analog = {
"address": "Екатеринбург, ул. Советская, 51",
"area_m2": 44.0,
"rooms": 2,
"floor": 3,
"total_floors": 9,
"price_rub": 6_000_000,
"price_per_m2": 136_000,
"listing_date": None,
"days_on_market": None,
"source": "cian",
"distance_m": 4381,
}
return SimpleNamespace(
id=_ESTIMATE_ID,
median_price=6_000_000,
range_low=5_500_000,
range_high=6_500_000,
median_price_per_m2=136_000,
confidence="low",
confidence_explanation="Найдено 42 аналогов",
n_analogs=42,
analogs=[analog, {**analog, "distance_m": 1200}],
actual_deals=[],
sources_used=["cian"],
data_freshness_minutes=15,
expires_at=datetime.now(tz=UTC) + timedelta(hours=12),
retain_until=None,
address="Свердловская область, г. Екатеринбург, ул. Советская, д. 51",
lat=56.8519,
lon=60.6122,
area_m2=44.0,
rooms=2,
floor=3,
total_floors=9,
year_built=1975,
house_type=None,
repair_state=None,
has_balcony=None,
canonical_address=None,
house_cadnum=None,
house_fias_id=None,
dadata_qc_geo=None,
dadata_metro=[],
expected_sold_price=None,
expected_sold_range_low=None,
expected_sold_range_high=None,
expected_sold_per_m2=None,
asking_to_sold_ratio=None,
ratio_basis=None,
created_by="praktika",
created_at=datetime.now(tz=UTC),
relaxations=["радиус расширен до 5000 м"],
reliability="low",
)
@pytest.fixture()
def _light_estimator_stub():
"""Подменяет тяжёлые POST-only helper'ы estimator'а, оставляя РЕАЛЬНУЮ реконструкцию.
Тот же приём, что в test_estimate_idor / test_estimate_revival (не тащить
геокодер/скрейперы в API-тест), но `rehydrate_search_radius_m` берётся
настоящий его поведение и проверяем.
"""
real_estimator = sys.modules.get("app.services.estimator")
stub = SimpleNamespace(
_qc_geo_to_precision=lambda _qc: None,
_fetch_price_trend=lambda *a, **k: None,
_fetch_dkp_corridor=lambda *a, **k: None,
_fetch_house_imv_anchor=lambda *a, **k: None,
_resolve_target_city=lambda *a, **k: None,
_cv_from_ppm2=lambda *a, **k: None,
_source_counts=lambda *a, **k: {},
_canonical_sources=lambda *a, **k: ["cian"],
rehydrate_search_radius_m=rehydrate_search_radius_m,
)
sys.modules["app.services.estimator"] = stub # type: ignore[assignment]
yield stub
if real_estimator is not None:
sys.modules["app.services.estimator"] = real_estimator
else:
sys.modules.pop("app.services.estimator", None)
@pytest.fixture(autouse=True)
def _restore_get_role():
from app.core import auth as auth_mod
original = auth_mod.get_role
yield
auth_mod.get_role = original
def test_get_estimate_returns_actual_search_radius(_light_estimator_stub: SimpleNamespace) -> None:
"""Прод-репро: круг на реоткрытой оценке рисовался по 1 км при поиске в 5 км."""
from app.api.v1 import trade_in as trade_in_module
from app.core import auth as auth_mod
from app.core.db import get_db
app = FastAPI()
app.include_router(trade_in_module.router, prefix="/api/v1/trade-in")
row = _prod_shaped_row()
db = MagicMock()
def _execute(clause, params=None, *_a, **_k):
sql = getattr(clause, "text", str(clause))
result = MagicMock()
result.fetchone.return_value = row if "SELECT id, median_price" in sql else None
return result
db.execute.side_effect = _execute
def _override_db():
yield db
app.dependency_overrides[get_db] = _override_db
auth_mod.get_role = lambda _u: "pilot" # type: ignore[assignment]
resp = TestClient(app).get(
f"/api/v1/trade-in/estimate/{_ESTIMATE_ID}",
headers={"X-Authenticated-User": "praktika"},
)
assert resp.status_code == 200
body = resp.json()
assert body["search_radius_m"] == 5000
# круг обязан вмещать пины, которые сам же показывает
assert body["search_radius_m"] >= max(a["distance_m"] for a in body["analogs"])
# вход не выдаём за результат: radius_m не персистится — «просили» неизвестно
assert body["requested_radius_m"] is None