fix(estimator): не выдавать цену, когда рядом нет ни одного аналога
Some checks failed
CI / changes (pull_request) Successful in 12s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / changes (pull_request) Successful in 9s
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Failing after 4m58s
Some checks failed
CI / changes (pull_request) Successful in 12s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / changes (pull_request) Successful in 9s
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Failing after 4m58s
Прод-репро: «Красногорск, Янтарная» возвращал 236 766 ₽/м² при n_analogs=0. Тестировщик открывает список аналогов, видит пустоту и справедливо перестаёт верить числу. Доля таких ответов 3.8% по ЕКБ и 4.8% по 77/50 — дефект продукта, не регионального покрытия. Источник цены нашёлся ровно один: deals-headline-fallback #oblast-D (estimator.py:4184-4225). Когда листинговых аналогов нет совсем, headline берётся из коридора ДКП Росреестра, а n_analogs остаётся 0. Остальные кандидаты проверены и исключены: IMV-blend и same-building anchor цену из воздуха не создают (первый no-op при median_price<=0, второй всегда проставляет n_analogs=anchor['n']), deal_city_price_bands — только sanity-clamp правдоподобия сделок. Гейт сужен до n_analogs==0 AND reliability=='very_low', а не голого n_analogs==0. Причина: случай #oblast-E (1-4 объявления плюс >=3 реальные сделки) тоже зануляет n_analogs, но там reliability намеренно закреплена на 'low' — коридор из полусотни сделок Росреестра это настоящий сигнал, и гасить его было бы регрессией задокументированного решения от 10.08.2026. Отказ переиспользует существующий паттерн _empty_estimate, но сохраняет уже найденные адрес/координаты/дом — фронту есть что показать рядом с причиной. Флаг estimate_require_analogs позволяет откатить без релиза.
This commit is contained in:
parent
1bd5283aa6
commit
4f4d8ee929
3 changed files with 206 additions and 0 deletions
|
|
@ -389,6 +389,20 @@ class Settings(BaseSettings):
|
||||||
estimate_imv_blend_weight: float = 0.5 # вес якоря в blend: median*(1-w)+A*w
|
estimate_imv_blend_weight: float = 0.5 # вес якоря в blend: median*(1-w)+A*w
|
||||||
estimate_imv_blend_threshold: float = 1.15 # якорь должен быть > медианы ×1.15
|
estimate_imv_blend_threshold: float = 1.15 # якорь должен быть > медианы ×1.15
|
||||||
|
|
||||||
|
# #estimate-zero-analogs (prod defect, 2026-09-16): прод отдавал точечную цену
|
||||||
|
# без единого сопоставимого объявления-аналога (n_analogs=0) — юзер видит
|
||||||
|
# уверенное число, но за ним нет объявлений рядом с домом (только каскад
|
||||||
|
# #oblast-D deals-headline-fallback по ДКП-коридору Росреестра И при этом
|
||||||
|
# reliability уже честно 'very_low', т.е. сам движок не доверяет числу
|
||||||
|
# больше, чем "почти нет сигнала"). Same-building/IMV anchor сюда НЕ
|
||||||
|
# попадает: якорь всегда задаёт n_analogs=anchor['n']>=минимума компл'ов,
|
||||||
|
# поэтому n_analogs==0 у него в принципе не бывает — гейт его не трогает.
|
||||||
|
# ДКП-коридор с reliability='low' (thin-но-реальные объявления + сделки,
|
||||||
|
# #oblast-E) тоже НЕ гасится — это осознанно защищённый продуктовый кейс
|
||||||
|
# (см. estimate_quality reliability-блок). True ⇒ новое поведение (честный
|
||||||
|
# отказ вместо точечной цены); False ⇒ откат к прежнему без релиза.
|
||||||
|
estimate_require_analogs: bool = True
|
||||||
|
|
||||||
# ── #651/#652 v2: same-building anchor (validated, 55 golden cases) ──────────
|
# ── #651/#652 v2: same-building anchor (validated, 55 golden cases) ──────────
|
||||||
# Радиусная медиана размывает премию дома/ЖК → премиум ~2.5x недооценка,
|
# Радиусная медиана размывает премию дома/ЖК → премиум ~2.5x недооценка,
|
||||||
# комфорт −15-25%. v2 берёт PRIMARY якорь из комплов ТОГО ЖЕ ДОМА (Tier A),
|
# комфорт −15-25%. v2 берёт PRIMARY якорь из комплов ТОГО ЖЕ ДОМА (Tier A),
|
||||||
|
|
|
||||||
|
|
@ -5540,6 +5540,49 @@ async def estimate_quality(
|
||||||
sources_used=sources_used,
|
sources_used=sources_used,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# #estimate-zero-analogs: честный отказ вместо точечной цены, когда нет ни
|
||||||
|
# одного сопоставимого аналога рядом. Гейт — n_analogs==0 AND reliability
|
||||||
|
# == 'very_low' (не просто n_analogs==0): same-building/IMV anchor никогда
|
||||||
|
# не даёт n_analogs==0 (anchor всегда задаёт n_analogs=anchor['n']>=min
|
||||||
|
# comps), поэтому гейт его не касается по построению. ДКП-коридор
|
||||||
|
# (#oblast-D) тоже может дать n_analogs==0 с ненулевой ценой — НО когда он
|
||||||
|
# опирается на реальные thin-listings + сделки (#oblast-E), reliability
|
||||||
|
# намеренно закреплена на 'low', а не 'very_low' (см. reliability-блок
|
||||||
|
# выше) — этот подтверждённый комбинированный сигнал НЕ гасим. Гасим
|
||||||
|
# только "n_analogs=0 и сам движок это уже оценивает как почти отсутствие
|
||||||
|
# сигнала" — чистый ДКП-only headline без единого объявления рядом и
|
||||||
|
# geocode-неудачи (_empty_estimate) — те же самые "insufficient data"
|
||||||
|
# состояния, просто с уже отработавшим геокодом/поиском дома.
|
||||||
|
#
|
||||||
|
# Механизм переиспользует уже существующий в коде паттерн "оценку
|
||||||
|
# построить не удалось" (_empty_estimate: median/range/ppm2=0,
|
||||||
|
# confidence='low', analogs/deals пустые) — но, в отличие от
|
||||||
|
# _empty_estimate (вызывается ТОЛЬКО при неудачном geocode), здесь geo/
|
||||||
|
# house-match уже есть и сохраняются в ответе, чтобы фронт мог показать
|
||||||
|
# осмысленный адрес + причину отказа, а не просто пустую карточку.
|
||||||
|
if settings.estimate_require_analogs and n_analogs == 0 and reliability == "very_low":
|
||||||
|
logger.info(
|
||||||
|
"estimate_require_analogs: suppressing point price (n_analogs=0,"
|
||||||
|
" reliability=very_low) addr=%r median_price_was=%s",
|
||||||
|
geo.full_address,
|
||||||
|
median_price,
|
||||||
|
)
|
||||||
|
median_price = 0
|
||||||
|
median_ppm2 = 0.0
|
||||||
|
range_low = 0
|
||||||
|
range_high = 0
|
||||||
|
expected_sold_price = None
|
||||||
|
expected_sold_per_m2 = None
|
||||||
|
expected_sold_range_low = None
|
||||||
|
expected_sold_range_high = None
|
||||||
|
market_percentile = None
|
||||||
|
api_analog_tier = None
|
||||||
|
explanation = (
|
||||||
|
"Недостаточно данных для точной оценки по этому адресу — рядом нет "
|
||||||
|
"сопоставимых объявлений и сделок. Попробуйте уточнить адрес или "
|
||||||
|
"обратиться к специалисту."
|
||||||
|
)
|
||||||
|
|
||||||
# #2002 #4: manual-review routing — производный ФЛАГ на основе уже
|
# #2002 #4: manual-review routing — производный ФЛАГ на основе уже
|
||||||
# вычисленных premium_building / confidence / range (asking-IQR). НЕ влияет
|
# вычисленных premium_building / confidence / range (asking-IQR). НЕ влияет
|
||||||
# на median/expected_sold/ranges — только метаданные для UI/маршрутизации.
|
# на median/expected_sold/ranges — только метаданные для UI/маршрутизации.
|
||||||
|
|
|
||||||
149
tradein-mvp/backend/tests/test_estimator_requires_analogs.py
Normal file
149
tradein-mvp/backend/tests/test_estimator_requires_analogs.py
Normal file
|
|
@ -0,0 +1,149 @@
|
||||||
|
"""Tests for #estimate-zero-analogs: honest refusal instead of a point price
|
||||||
|
when n_analogs == 0 and the engine itself already trusts the number no more
|
||||||
|
than "почти нет сигнала" (reliability == 'very_low').
|
||||||
|
|
||||||
|
Repro this guards against (prod, 2026-09-16): "Красногорск, Янтарная" returned
|
||||||
|
a confident 236 766 руб/м² with n_analogs=0 — 3.8% of ЕКБ estimates and 4.8% of
|
||||||
|
region 77/50 estimates hit this shape.
|
||||||
|
|
||||||
|
Same isolation harness as test_estimator_expected_sold.py: `estimate_quality`
|
||||||
|
with every I/O dependency stubbed (no DB, no network).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from typing import Any
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import anyio
|
||||||
|
|
||||||
|
# Settings requires DATABASE_URL at init time. Set dummy DSN before any app import.
|
||||||
|
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
|
||||||
|
|
||||||
|
|
||||||
|
def _make_fake_geo():
|
||||||
|
from app.services.geocoder import GeocodeResult
|
||||||
|
|
||||||
|
return GeocodeResult(
|
||||||
|
lat=55.831,
|
||||||
|
lon=37.330,
|
||||||
|
full_address="Московская обл., Красногорск, ул. Янтарная, 5",
|
||||||
|
provider="nominatim",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_payload():
|
||||||
|
from app.schemas.trade_in import TradeInEstimateInput
|
||||||
|
|
||||||
|
return TradeInEstimateInput(
|
||||||
|
address="Красногорск, ул. Янтарная, 5",
|
||||||
|
area_m2=40.0,
|
||||||
|
rooms=1,
|
||||||
|
floor=4,
|
||||||
|
total_floors=16,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_estimate(
|
||||||
|
*,
|
||||||
|
require_analogs: bool,
|
||||||
|
dkp_raw: dict[str, Any] | None = None,
|
||||||
|
):
|
||||||
|
"""estimate_quality: пустой радиус (listings=[]), нет anchor — только
|
||||||
|
опциональный ДКП-коридор (`dkp_raw`) через `_fetch_dkp_corridor`."""
|
||||||
|
from app.services.estimator import estimate_quality
|
||||||
|
|
||||||
|
db = MagicMock()
|
||||||
|
payload = _make_payload()
|
||||||
|
|
||||||
|
async def _run():
|
||||||
|
with (
|
||||||
|
patch("app.services.estimator.settings.estimate_require_analogs", require_analogs),
|
||||||
|
patch("app.services.estimator.geocode", new=AsyncMock(return_value=_make_fake_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)),
|
||||||
|
# Ничего не нашли в радиусе — listings_clean=[] → median_price=0 до фолбэков.
|
||||||
|
patch("app.services.estimator._fetch_analogs", return_value=([], False, None)),
|
||||||
|
patch("app.services.estimator._fetch_deals", return_value=[]),
|
||||||
|
patch("app.services.estimator._fetch_dkp_corridor", return_value=dkp_raw),
|
||||||
|
# Ни same-building, ни micro-radius anchor.
|
||||||
|
patch("app.services.estimator._fetch_anchor_comps", return_value=([], None)),
|
||||||
|
patch("app.services.estimator._fetch_house_imv_anchor", return_value=None),
|
||||||
|
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._get_asking_sold_ratio", return_value=(None, None)),
|
||||||
|
):
|
||||||
|
return await estimate_quality(payload, db)
|
||||||
|
|
||||||
|
return anyio.run(_run)
|
||||||
|
|
||||||
|
|
||||||
|
def test_true_zero_data_suppresses_price_when_flag_on() -> None:
|
||||||
|
"""Ни listings, ни anchor, ни ДКП-коридора вообще → n_analogs=0,
|
||||||
|
reliability='very_low' изначально (без ДКП-фолбэка median остаётся 0
|
||||||
|
и без флага) — но проверяем именно flag=True ветку на honest-explanation."""
|
||||||
|
est = _run_estimate(require_analogs=True, dkp_raw=None)
|
||||||
|
|
||||||
|
assert est.n_analogs == 0
|
||||||
|
assert est.median_price_rub == 0
|
||||||
|
assert est.median_price_per_m2 == 0
|
||||||
|
assert est.range_low_rub == 0
|
||||||
|
assert est.range_high_rub == 0
|
||||||
|
assert est.expected_sold_price_rub is None
|
||||||
|
assert est.confidence_explanation is not None
|
||||||
|
assert "Недостаточно данных" in est.confidence_explanation
|
||||||
|
|
||||||
|
|
||||||
|
def test_dkp_only_headline_suppressed_when_flag_on() -> None:
|
||||||
|
"""#oblast-D: n_analogs=0 (чисто ДКП-коридор, без единого объявления)
|
||||||
|
даёт ненулевую цену по mainline (reliability='very_low' по построению —
|
||||||
|
thin_listings-флаг не срабатывает, т.к. listings изначально пустые) —
|
||||||
|
с флагом=True цена должна быть подавлена."""
|
||||||
|
dkp_raw = {
|
||||||
|
"count": 12,
|
||||||
|
"median_ppm2": 236_766.0,
|
||||||
|
"low_ppm2": 210_000.0,
|
||||||
|
"high_ppm2": 260_000.0,
|
||||||
|
"period_months": 12,
|
||||||
|
}
|
||||||
|
est = _run_estimate(require_analogs=True, dkp_raw=dkp_raw)
|
||||||
|
|
||||||
|
assert est.n_analogs == 0
|
||||||
|
assert est.median_price_rub == 0, "точечная цена не должна выдаваться при нуле аналогов"
|
||||||
|
assert est.median_price_per_m2 == 0
|
||||||
|
assert est.range_low_rub == 0
|
||||||
|
assert est.range_high_rub == 0
|
||||||
|
assert est.expected_sold_price_rub is None
|
||||||
|
assert est.expected_sold_range_low_rub is None
|
||||||
|
assert est.expected_sold_range_high_rub is None
|
||||||
|
assert "Недостаточно данных" in (est.confidence_explanation or "")
|
||||||
|
# Адрес/гео — НЕ ценовые поля — сохраняются, чтобы фронт мог показать адрес.
|
||||||
|
assert est.target_address is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_dkp_only_headline_kept_when_flag_off() -> None:
|
||||||
|
"""Флаг False → старое поведение: ДКП-коридор по-прежнему headline'ит
|
||||||
|
цену при n_analogs=0 (откат без релиза)."""
|
||||||
|
dkp_raw = {
|
||||||
|
"count": 12,
|
||||||
|
"median_ppm2": 236_766.0,
|
||||||
|
"low_ppm2": 210_000.0,
|
||||||
|
"high_ppm2": 260_000.0,
|
||||||
|
"period_months": 12,
|
||||||
|
}
|
||||||
|
est = _run_estimate(require_analogs=False, dkp_raw=dkp_raw)
|
||||||
|
|
||||||
|
assert est.n_analogs == 0
|
||||||
|
assert est.median_price_rub > 0, "flag=False должен сохранять старое (ДКП-headline) поведение"
|
||||||
|
assert est.median_price_per_m2 == 236_766
|
||||||
Loading…
Add table
Reference in a new issue