From 4f4d8ee929283a013293fa52f3cf87f00296539d Mon Sep 17 00:00:00 2001 From: bot-backend Date: Wed, 16 Sep 2026 19:21:26 +0300 Subject: [PATCH 1/3] =?UTF-8?q?fix(estimator):=20=D0=BD=D0=B5=20=D0=B2?= =?UTF-8?q?=D1=8B=D0=B4=D0=B0=D0=B2=D0=B0=D1=82=D1=8C=20=D1=86=D0=B5=D0=BD?= =?UTF-8?q?=D1=83,=20=D0=BA=D0=BE=D0=B3=D0=B4=D0=B0=20=D1=80=D1=8F=D0=B4?= =?UTF-8?q?=D0=BE=D0=BC=20=D0=BD=D0=B5=D1=82=20=D0=BD=D0=B8=20=D0=BE=D0=B4?= =?UTF-8?q?=D0=BD=D0=BE=D0=B3=D0=BE=20=D0=B0=D0=BD=D0=B0=D0=BB=D0=BE=D0=B3?= =?UTF-8?q?=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Прод-репро: «Красногорск, Янтарная» возвращал 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 позволяет откатить без релиза. --- tradein-mvp/backend/app/core/config.py | 14 ++ tradein-mvp/backend/app/services/estimator.py | 43 +++++ .../tests/test_estimator_requires_analogs.py | 149 ++++++++++++++++++ 3 files changed, 206 insertions(+) create mode 100644 tradein-mvp/backend/tests/test_estimator_requires_analogs.py diff --git a/tradein-mvp/backend/app/core/config.py b/tradein-mvp/backend/app/core/config.py index 485484d8..cebfed71 100644 --- a/tradein-mvp/backend/app/core/config.py +++ b/tradein-mvp/backend/app/core/config.py @@ -389,6 +389,20 @@ class Settings(BaseSettings): estimate_imv_blend_weight: float = 0.5 # вес якоря в blend: median*(1-w)+A*w 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) ────────── # Радиусная медиана размывает премию дома/ЖК → премиум ~2.5x недооценка, # комфорт −15-25%. v2 берёт PRIMARY якорь из комплов ТОГО ЖЕ ДОМА (Tier A), diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index d450269d..aaf0a4b4 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -5540,6 +5540,49 @@ async def estimate_quality( 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 — производный ФЛАГ на основе уже # вычисленных premium_building / confidence / range (asking-IQR). НЕ влияет # на median/expected_sold/ranges — только метаданные для UI/маршрутизации. diff --git a/tradein-mvp/backend/tests/test_estimator_requires_analogs.py b/tradein-mvp/backend/tests/test_estimator_requires_analogs.py new file mode 100644 index 00000000..ba77b80a --- /dev/null +++ b/tradein-mvp/backend/tests/test_estimator_requires_analogs.py @@ -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 -- 2.45.3 From 182f1d2802ef7e3eb9475339cf629cebff1c7db2 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Wed, 16 Sep 2026 19:55:05 +0300 Subject: [PATCH 2/3] =?UTF-8?q?fix(estimator):=20=D1=80=D0=B5=D0=B7=D0=B0?= =?UTF-8?q?=D1=82=D1=8C=20=D0=BE=D0=B1=D1=89=D0=B5=D0=B3=D0=BE=D1=80=D0=BE?= =?UTF-8?q?=D0=B4=D1=81=D0=BA=D0=BE=D0=B9=20=D0=BA=D0=BE=D1=80=D0=B8=D0=B4?= =?UTF-8?q?=D0=BE=D1=80,=20=D0=B0=20=D0=BD=D0=B5=20=D0=BB=D1=8E=D0=B1?= =?UTF-8?q?=D1=83=D1=8E=20=D1=86=D0=B5=D0=BD=D1=83=20=D0=B1=D0=B5=D0=B7=20?= =?UTF-8?q?=D0=B0=D0=BD=D0=B0=D0=BB=D0=BE=D0=B3=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Первая версия гейта гасила цену всегда при n_analogs==0 — и уронила контракт #oblast-D: тест test_non_ekb_empty_listings_uses_deals_headline (Нижний Тагил, 12 сделок по улице) падал. Это не случайный тест, а фича, без которой в область протекал екатеринбургский asking вшестеро выше. Проверка на проде показала, где настоящая граница. У Янтарной в Красногорске ОДНА сделка — меньше DKP_CORRIDOR_CITY_WIDE_MIN_N=3, поэтому _fetch_dkp_corridor расширился до всего города: 4770 сделок, медиана 200 676 ₽/м². Пользователю показали среднюю цену Красногорска как оценку его квартиры. А 12 сделок по улице в Тагиле — настоящий сигнал. Коридор уже сам помечает себя scope='street' | 'city_wide', так что правило формулируется точно: общегородской коридор не может стать headline, уличный может. Подавление ответа осталось только оформлением пустого результата — условие median_price <= 0 намеренно совпадает с computed-полем AggregatedEstimate.insufficient_data, по которому фронт уже рисует карточку, чтобы признак был один, а не два расходящихся. Флаг переименован в estimate_require_local_evidence: он не про «требовать аналоги», а про «цена обязана опираться на данные этого адреса». Обе части правила под одним флагом — False возвращает прежнее поведение целиком. --- tradein-mvp/backend/app/core/config.py | 31 +++++----- tradein-mvp/backend/app/services/estimator.py | 50 +++++++++------- .../tests/test_estimator_requires_analogs.py | 58 +++++++++++++------ 3 files changed, 86 insertions(+), 53 deletions(-) diff --git a/tradein-mvp/backend/app/core/config.py b/tradein-mvp/backend/app/core/config.py index cebfed71..4c2258b3 100644 --- a/tradein-mvp/backend/app/core/config.py +++ b/tradein-mvp/backend/app/core/config.py @@ -389,19 +389,24 @@ class Settings(BaseSettings): estimate_imv_blend_weight: float = 0.5 # вес якоря в blend: median*(1-w)+A*w 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 + # #estimate-zero-analogs (прод-дефект, 2026-09-16): цена обязана опираться на + # данные ЭТОГО адреса, а не на среднее по городу. + # Репро: «Красногорск, Янтарная» отдавал 236 766 ₽/м², не имея рядом ни одного + # объявления. Число приходило из ДКП-коридора Росреестра, а тот при тонкой + # улице (n < DKP_CORRIDOR_CITY_WIDE_MIN_N = 3) расширяется до всего города и + # помечает себя scope="city_wide". Проверено на проде: у Янтарной ОДНА сделка, + # по Красногорску 4770 с медианой 200 676 ₽/м² — пользователю показали среднюю + # цену города как оценку его квартиры. Доля таких ответов 3.8% по ЕКБ, 4.8% по + # 77/50, то есть дефект продукта, а не регионального покрытия. + # Флаг делает две вещи, обе откатываются одним переключателем: + # 1) общегородской коридор больше не может стать headline (уличный — может: + # Нижний Тагил с 12 сделками по улице это настоящий сигнал, ради него + # #oblast-D и делали, иначе туда протекал екатеринбургский asking вшестеро + # выше); + # 2) если headline так и не появился, ответ уходит честной карточкой + # «недостаточно данных», а не нулями. + # False ⇒ прежнее поведение целиком, без релиза. + estimate_require_local_evidence: bool = True # ── #651/#652 v2: same-building anchor (validated, 55 golden cases) ────────── # Радиусная медиана размывает премию дома/ЖК → премиум ~2.5x недооценка, diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index aaf0a4b4..5f4aff20 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -4181,12 +4181,24 @@ def _price_from_inputs( # anchor is None: anchor_tier = None` в anchor-блоке выше), т.е. два условия # стали эквивалентны. Гард оставлен на `anchor is None` НАМЕРЕННО — это # по-прежнему прямая проверка факта «якорь дал headline», а не производный флаг. + # #estimate-zero-analogs (2026-09-16): коридор годится в HEADLINE только пока + # он про ЭТОТ адрес. _fetch_dkp_corridor при тонкой улице (n < + # DKP_CORRIDOR_CITY_WIDE_MIN_N = 3) расширяется до всего города и помечает + # себя scope="city_wide" — такая медиана описывает город, а не квартиру, и + # выдавать её как оценку нельзя. Прод-репро: «Красногорск, Янтарная» вернул + # 236 766 ₽/м² при ОДНОЙ сделке на улице; по городу 4770 сделок с медианой + # 200 676 ₽/м² — пользователю показали среднюю цену Красногорска. Уличный + # коридор (Нижний Тагил, 12 сделок) — настоящий сигнал и остаётся headline: + # без него туда протекал екатеринбургский asking вшестеро выше (#oblast-D). if ( median_ppm2 <= 0 and anchor is None and dkp_raw is not None and dkp_raw.get("count", 0) >= DEALS_HEADLINE_FALLBACK_MIN_N and dkp_raw.get("median_ppm2", 0) > 0 + and not ( + settings.estimate_require_local_evidence and dkp_raw.get("scope") == "city_wide" + ) ): median_ppm2 = float(dkp_raw["median_ppm2"]) median_price = int(median_ppm2 * area_m2) @@ -5540,30 +5552,24 @@ async def estimate_quality( 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" - # состояния, просто с уже отработавшим геокодом/поиском дома. + # #estimate-zero-analogs (2026-09-16): честный отказ вместо пустой карточки. + # Отбор, КОМУ вообще позволено дать цену без листинговых аналогов, сделан + # выше — уличный ДКП-коридор может, общегородской нет. Здесь остаётся + # оформление: если после всей цепочки headline так и не появился, ответ не + # должен уезжать наружу нулями. Условие median_price <= 0 намеренно + # совпадает с computed-полем AggregatedEstimate.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": + # Механизм переиспользует существующий паттерн _empty_estimate + # (median/range/ppm2 = 0, confidence='low', analogs/deals пустые), но, в + # отличие от него (вызывается ТОЛЬКО при неудачном geocode), здесь geo и + # house-match уже есть и сохраняются — фронту есть что показать рядом с + # причиной отказа. + if settings.estimate_require_local_evidence and n_analogs == 0 and median_price <= 0: logger.info( - "estimate_require_analogs: suppressing point price (n_analogs=0," - " reliability=very_low) addr=%r median_price_was=%s", + "estimate_require_local_evidence: suppressing point price (n_analogs=0," + " median_price<=0) addr=%r median_price_was=%s", geo.full_address, median_price, ) diff --git a/tradein-mvp/backend/tests/test_estimator_requires_analogs.py b/tradein-mvp/backend/tests/test_estimator_requires_analogs.py index ba77b80a..8beca395 100644 --- a/tradein-mvp/backend/tests/test_estimator_requires_analogs.py +++ b/tradein-mvp/backend/tests/test_estimator_requires_analogs.py @@ -59,7 +59,7 @@ def _run_estimate( async def _run(): with ( - patch("app.services.estimator.settings.estimate_require_analogs", require_analogs), + patch("app.services.estimator.settings.estimate_require_local_evidence", 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), @@ -105,45 +105,67 @@ def test_true_zero_data_suppresses_price_when_flag_on() -> 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 цена должна быть подавлена.""" +def test_city_wide_corridor_never_becomes_headline() -> None: + """Прод-репро «Красногорск, Янтарная»: у улицы одна сделка, поэтому коридор + расширился до всего города (scope='city_wide', 4770 сделок, медиана + 200 676 ₽/м²) и выдал её как оценку конкретной квартиры. Средняя цена + города — не оценка адреса, поэтому цены быть не должно.""" dkp_raw = { - "count": 12, - "median_ppm2": 236_766.0, - "low_ppm2": 210_000.0, + "count": 4770, + "median_ppm2": 200_676.0, + "low_ppm2": 150_000.0, "high_ppm2": 260_000.0, "period_months": 12, + "scope": "city_wide", } 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_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 est.insufficient_data is True 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 (откат без релиза).""" +def test_street_scoped_corridor_still_headlines() -> None: + """Обратная сторона того же правила: уличный коридор — настоящий сигнал и + цену даёт. Это защита #oblast-D (Нижний Тагил), без которой туда протекал + екатеринбургский asking вшестеро выше.""" dkp_raw = { "count": 12, - "median_ppm2": 236_766.0, - "low_ppm2": 210_000.0, + "median_ppm2": 85_911.0, + "low_ppm2": 70_000.0, + "high_ppm2": 100_000.0, + "period_months": 12, + "scope": "street", + } + est = _run_estimate(require_analogs=True, dkp_raw=dkp_raw) + + assert est.n_analogs == 0 + assert est.median_price_per_m2 == 85_911, "уличный коридор обязан остаться headline" + assert est.insufficient_data is False + + +def test_city_wide_corridor_kept_when_flag_off() -> None: + """Флаг False → прежнее поведение целиком: общегородской коридор снова + становится headline. Откат без релиза.""" + dkp_raw = { + "count": 4770, + "median_ppm2": 200_676.0, + "low_ppm2": 150_000.0, "high_ppm2": 260_000.0, "period_months": 12, + "scope": "city_wide", } 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 + assert est.median_price_rub > 0, "flag=False должен возвращать прежнее поведение" + assert est.median_price_per_m2 == 200_676 -- 2.45.3 From 39af474f44689bfcdc806ac621f8f50e3714ee00 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Wed, 16 Sep 2026 19:56:36 +0300 Subject: [PATCH 3/3] =?UTF-8?q?style:=20ruff=20format=20=D0=BF=D0=BE=D1=81?= =?UTF-8?q?=D0=BB=D0=B5=20=D0=BF=D1=80=D0=B0=D0=B2=D0=BA=D0=B8=20=D0=B3?= =?UTF-8?q?=D0=B5=D0=B9=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tradein-mvp/backend/app/services/estimator.py | 4 +--- tradein-mvp/backend/tests/test_estimator_requires_analogs.py | 4 +++- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index 5f4aff20..e3f96093 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -4196,9 +4196,7 @@ def _price_from_inputs( and dkp_raw is not None and dkp_raw.get("count", 0) >= DEALS_HEADLINE_FALLBACK_MIN_N and dkp_raw.get("median_ppm2", 0) > 0 - and not ( - settings.estimate_require_local_evidence and dkp_raw.get("scope") == "city_wide" - ) + and not (settings.estimate_require_local_evidence and dkp_raw.get("scope") == "city_wide") ): median_ppm2 = float(dkp_raw["median_ppm2"]) median_price = int(median_ppm2 * area_m2) diff --git a/tradein-mvp/backend/tests/test_estimator_requires_analogs.py b/tradein-mvp/backend/tests/test_estimator_requires_analogs.py index 8beca395..61ecb52b 100644 --- a/tradein-mvp/backend/tests/test_estimator_requires_analogs.py +++ b/tradein-mvp/backend/tests/test_estimator_requires_analogs.py @@ -59,7 +59,9 @@ def _run_estimate( async def _run(): with ( - patch("app.services.estimator.settings.estimate_require_local_evidence", require_analogs), + patch( + "app.services.estimator.settings.estimate_require_local_evidence", 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), -- 2.45.3