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
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:
parent
4d31a0ee82
commit
dae3f5bc41
6 changed files with 368 additions and 15 deletions
|
|
@ -556,11 +556,22 @@ def get_estimate(
|
||||||
_qc_geo_to_precision,
|
_qc_geo_to_precision,
|
||||||
_resolve_target_city,
|
_resolve_target_city,
|
||||||
_source_counts,
|
_source_counts,
|
||||||
|
rehydrate_search_radius_m,
|
||||||
)
|
)
|
||||||
|
|
||||||
analogs = [AnalogLot(**a) for a in (row.analogs or [])]
|
analogs = [AnalogLot(**a) for a in (row.analogs or [])]
|
||||||
actual_deals = [AnalogLot(**a) for a in (row.actual_deals 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 из
|
# #2043 (BE-1): CV / счётчики источников на rehydrate — best-effort из
|
||||||
# сохранённых analogs (top-N, усечённо: полная выборка не персистится). На
|
# сохранённых analogs (top-N, усечённо: полная выборка не персистится). На
|
||||||
# свежей оценке (POST) считаются по полной выборке; здесь — по тому, что есть
|
# свежей оценке (POST) считаются по полной выборке; здесь — по тому, что есть
|
||||||
|
|
@ -689,8 +700,12 @@ def get_estimate(
|
||||||
# getattr defensive: старые in-memory test doubles / любая строка без
|
# getattr defensive: старые in-memory test doubles / любая строка без
|
||||||
# этих колонок (не должно случаться после миграции) деградируют в
|
# этих колонок (не должно случаться после миграции) деградируют в
|
||||||
# дефолт схемы (ok / []), а не падают AttributeError.
|
# дефолт схемы (ok / []), а не падают AttributeError.
|
||||||
relaxations=list(getattr(row, "relaxations", None) or []),
|
relaxations=persisted_relaxations,
|
||||||
reliability=getattr(row, "reliability", None) or "ok",
|
reliability=getattr(row, "reliability", None) or "ok",
|
||||||
|
# #2632: фактический радиус подбора (реконструкция выше). requested_radius_m
|
||||||
|
# осознанно НЕ заполняем — payload.radius_m не персистится, и подставить
|
||||||
|
# сюда дефолт значило бы выдать догадку за то, что просил пользователь.
|
||||||
|
search_radius_m=search_radius_m,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -275,15 +275,26 @@ class AggregatedEstimate(BaseModel):
|
||||||
# НЕ удаляет/заменяет 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 — фактический радиус (метры), по которому реально отбирались
|
# search_radius_m — фактический радиус (метры), по которому реально отбирались
|
||||||
# listings-аналоги (estimator.py: base_radius_m/fallback_radius_m, #2632). Может
|
# listings-аналоги (estimator.py, #2632). Может ОТЛИЧАТЬСЯ от requested_radius_m:
|
||||||
# ОТЛИЧАТЬСЯ от TradeInEstimateInput.radius_m (выбор пользователя в дропдауне):
|
# при нехватке аналогов сервер расширяет поиск сам (1 км → 2 км, дальше каскад
|
||||||
# сервер молча расширяет 1 км → 2 км при нехватке аналогов (см.
|
# #oblast-F до 3/5 км — только когда пользователь НЕ зафиксировал радиус явно,
|
||||||
# confidence_explanation "расширили радиус до 2 км"). Фронт рисует круг на карте
|
# контракт #2044). Фронт рисует круг на карте по ЭТОМУ полю (не по своему
|
||||||
# по ЭТОМУ полю (не по своему выбору) — иначе карта врёт о реально
|
# выбору) — иначе карта врёт о реально использованном радиусе.
|
||||||
# использованном радиусе. None на GET-rehydrate (не персистится, старые записи)
|
# На GET-rehydrate колонки под него нет, поэтому значение ВОССТАНАВЛИВАЕТСЯ
|
||||||
# и у _empty_estimate (поиск аналогов не выполнялся) — фронт в этом случае
|
# (estimator.rehydrate_search_radius_m): из persisted-подписи каскада
|
||||||
# fallback'ает на выбор пользователя.
|
# «радиус расширен до N м» (точное значение, строки с 2026-08-10), иначе из
|
||||||
|
# размаха сохранённых аналогов, но не меньше DEFAULT_RADIUS_M. None — у
|
||||||
|
# _empty_estimate (поиск не выполнялся) и у старых строк без расстояний;
|
||||||
|
# фронт тогда fallback'ает на выбор пользователя, как раньше.
|
||||||
search_radius_m: int | None = None
|
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: премиальный дом (флаг, НЕ ценовой сигнал) ──
|
# ── #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-
|
||||||
|
|
|
||||||
|
|
@ -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_STEP1_M = 3000 # #oblast-F (d.1): max(текущий search_radius_m, 3000)
|
||||||
RELAX_RADIUS_STEP2_M = 5000 # #oblast-F (d.2): финальный максимум
|
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.
|
# #794: СберИндекс time-adjustment of frozen Rosreestr ДКП deals.
|
||||||
# Rosreestr deals freeze ~2026-01; the sber monthly index re-bases a stale deal's ppm²
|
# 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 = ЕКБ).
|
# to the latest available month. Region fixed to Свердловская обл. (tradein MVP = ЕКБ).
|
||||||
|
|
@ -2630,6 +2681,10 @@ def _price_from_inputs(
|
||||||
dadata_coarse: bool,
|
dadata_coarse: bool,
|
||||||
geo: GeocodeResult,
|
geo: GeocodeResult,
|
||||||
dadata_qc_geo: int | None,
|
dadata_qc_geo: int | None,
|
||||||
|
# #2632: фактический радиус подбора (после каскада #oblast-F) — чтобы
|
||||||
|
# объяснение называло тот радиус, по которому реально искали. None →
|
||||||
|
# прежнее поведение (FALLBACK_RADIUS_M) для оффлайн-вызывающих (бэктест).
|
||||||
|
search_radius_m: int | None = None,
|
||||||
) -> PricingResult:
|
) -> PricingResult:
|
||||||
"""Deterministic pricing orchestration — pure, synchronous, zero I/O.
|
"""Deterministic pricing orchestration — pure, synchronous, zero I/O.
|
||||||
|
|
||||||
|
|
@ -2795,6 +2850,7 @@ def _price_from_inputs(
|
||||||
fallback_used,
|
fallback_used,
|
||||||
area_widened,
|
area_widened,
|
||||||
listings=listings_clean,
|
listings=listings_clean,
|
||||||
|
radius_used_m=search_radius_m,
|
||||||
)
|
)
|
||||||
# #oblast-E/#oblast-F: honest low-reliability note — ONLY for case (ii) of
|
# #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
|
# the gate above (real thin median kept, no usable deals corridor to cede
|
||||||
|
|
@ -4000,7 +4056,7 @@ async def estimate_quality(
|
||||||
if area_widened:
|
if area_widened:
|
||||||
relaxations.append("площадь ±25%")
|
relaxations.append("площадь ±25%")
|
||||||
if search_radius_m > base_radius_m:
|
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) ──────────────
|
# ── PRE-FETCH: dkp_raw (hoisted before _price_from_inputs) ──────────────
|
||||||
# #1795: ДКП-коридор фетчим ДО вызова _price_from_inputs, чтобы
|
# #1795: ДКП-коридор фетчим ДО вызова _price_from_inputs, чтобы
|
||||||
|
|
@ -4192,6 +4248,7 @@ async def estimate_quality(
|
||||||
dadata_coarse=dadata_coarse,
|
dadata_coarse=dadata_coarse,
|
||||||
geo=geo,
|
geo=geo,
|
||||||
dadata_qc_geo=dadata.qc_geo if dadata else None,
|
dadata_qc_geo=dadata.qc_geo if dadata else None,
|
||||||
|
search_radius_m=search_radius_m,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Unpack pricing result
|
# Unpack pricing result
|
||||||
|
|
@ -4631,10 +4688,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
|
# #2632: обе величины рядом — с чего поиск начали (выбор пользователя или
|
||||||
# def выше) — может отличаться от payload.radius_m (выбор пользователя),
|
# DEFAULT_RADIUS_M) и на чём закончили. Расходятся, когда сервер сам
|
||||||
# когда сервер сам расширил поиск. None только у _empty_estimate (поиск
|
# расширил поиск; подменять одно другим нельзя — это ровно тот дефект
|
||||||
# аналогов вообще не выполнялся).
|
# метки, что чинили в #2811. None у _empty_estimate (поиск не выполнялся).
|
||||||
|
requested_radius_m=base_radius_m,
|
||||||
search_radius_m=search_radius_m,
|
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,
|
||||||
|
|
@ -6668,6 +6726,11 @@ def _cap_confidence_by_reliability(confidence: str, reliability: str) -> str:
|
||||||
return confidence
|
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(
|
def _compute_confidence(
|
||||||
n_analogs: int,
|
n_analogs: int,
|
||||||
median_ppm2: float,
|
median_ppm2: float,
|
||||||
|
|
@ -6676,6 +6739,7 @@ def _compute_confidence(
|
||||||
fallback_radius_used: bool,
|
fallback_radius_used: bool,
|
||||||
area_widened: bool = False,
|
area_widened: bool = False,
|
||||||
listings: list[dict] | None = None,
|
listings: list[dict] | None = None,
|
||||||
|
radius_used_m: int | None = None,
|
||||||
) -> tuple[str, str]:
|
) -> tuple[str, str]:
|
||||||
"""Confidence + explanation string.
|
"""Confidence + explanation string.
|
||||||
|
|
||||||
|
|
@ -6709,7 +6773,12 @@ def _compute_confidence(
|
||||||
iqr_pct = iqr / median_ppm2 if median_ppm2 > 0 else 1.0
|
iqr_pct = iqr / median_ppm2 if median_ppm2 > 0 else 1.0
|
||||||
notes = []
|
notes = []
|
||||||
if fallback_radius_used:
|
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:
|
if area_widened:
|
||||||
notes.append("расширили допуск по площади до ±25%")
|
notes.append("расширили допуск по площади до ±25%")
|
||||||
fallback_note = f" ({', '.join(notes)} из-за нехватки данных)" if notes else ""
|
fallback_note = f" ({', '.join(notes)} из-за нехватки данных)" if notes else ""
|
||||||
|
|
|
||||||
|
|
@ -163,6 +163,10 @@ def _stub_precision_and_pdf():
|
||||||
# #2087 (M1): GET-rehydrate derives canonical sources_used via the shared
|
# #2087 (M1): GET-rehydrate derives canonical sources_used via the shared
|
||||||
# helper. Empty analogs + no valuation flags → [] (real behaviour).
|
# helper. Empty analogs + no valuation flags → [] (real behaviour).
|
||||||
_canonical_sources=lambda *a, **k: [],
|
_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")
|
real_estimator = sys.modules.get("app.services.estimator")
|
||||||
sys.modules["app.services.estimator"] = estimator_stub # type: ignore[assignment]
|
sys.modules["app.services.estimator"] = estimator_stub # type: ignore[assignment]
|
||||||
|
|
|
||||||
|
|
@ -273,6 +273,10 @@ def _estimator_stub():
|
||||||
_cv_from_ppm2=lambda *a, **k: None,
|
_cv_from_ppm2=lambda *a, **k: None,
|
||||||
_source_counts=lambda *a, **k: {},
|
_source_counts=lambda *a, **k: {},
|
||||||
_canonical_sources=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,
|
estimate_quality=_default_estimate_quality,
|
||||||
)
|
)
|
||||||
sys.modules["app.services.estimator"] = stub # type: ignore[assignment]
|
sys.modules["app.services.estimator"] = stub # type: ignore[assignment]
|
||||||
|
|
|
||||||
250
tradein-mvp/backend/tests/test_estimator_search_radius_2632.py
Normal file
250
tradein-mvp/backend/tests/test_estimator_search_radius_2632.py
Normal 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
|
||||||
Loading…
Add table
Reference in a new issue