diff --git a/tradein-mvp/backend/app/schemas/trade_in.py b/tradein-mvp/backend/app/schemas/trade_in.py index 4517b236..00d6c074 100644 --- a/tradein-mvp/backend/app/schemas/trade_in.py +++ b/tradein-mvp/backend/app/schemas/trade_in.py @@ -158,10 +158,14 @@ class DkpCorridor(BaseModel): """Коридор реальных ДКП-сделок Росреестра для target (#652). Источник: `deals` (source='rosreestr', ДКП-only), агрегированные по улице + - rooms + площади ±15% за период. ADVISORY: показывается как тонкая референсная - линия «коридор реальных сделок: X–Y млн»; если итоговая медиана ₽/м² выходит - за [low,high]×slack — добавляется текстовая пометка. НЕ хард-клампит оценку. + rooms + площади ±15% за период. Показывается как тонкая референсная линия + «коридор реальных сделок: X–Y млн»; если итоговая медиана ₽/м² выходит за + [low,high]×slack — добавляется текстовая пометка. None / count=0 если по улице нет сопоставимых сделок. + + #3452: «advisory» здесь НЕ безусловно. При count >= estimate_corridor_clamp_min_n + коридор участвует в цене (soft-кламп headline + radius-floor, estimator.py), ниже + порога — не участвует. Что именно случилось с ЭТОЙ выборкой, говорит advisory_only. """ count: int # число ДКП-сделок в выборке @@ -183,6 +187,30 @@ class DkpCorridor(BaseModel): # None = сделки без даты (в проде не встречается) — потребитель молчит. latest_deal_date: date | None = None + @computed_field # type: ignore[prop-decorator] + @property + def advisory_only(self) -> bool: + """#3452: True = сделок меньше порога, ценовые страховки коридора выключены. + + Порог один и тот же (`estimate_corridor_clamp_min_n`) у обоих СТРАХОВОЧНЫХ + путей коридора: soft-кламп headline сверху и radius-floor снизу + (estimator.py). Ниже него коридор всё ещё виден клиенту, но не держит + цену — зона n=3..9 на экране была неотличима от работающей. + + ВНИМАНИЕ, поле НЕ значит «коридор в цену не вошёл»: гейт Tier C + (#1795 шаг 3) сравнивает якорь с потолком коридора БЕЗ порога вообще, и + deals-headline-fallback берёт медиану коридора начиная с трёх сделок. + Потребителю (витрине) поэтому корректно говорить про РАЗМЕР ВЫБОРКИ, а + не про то, что цену коридор не трогал. + + Производное от count, поэтому верно во ВСЕХ конструкторах DkpCorridor + автоматически (POST /estimate и GET-rehydrate) и не дублирует порог + вторым числом. + """ + from app.core.config import settings # локально: schemas остаётся import-light + + return self.count < settings.estimate_corridor_clamp_min_n + class PriceTrendPoint(BaseModel): """Одна точка месячного ₽/м² тренда для целевого дома / района (web TREND chart). diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index 8556f6ec..ac9089f6 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -2171,6 +2171,11 @@ def _fetch_dkp_corridor( if dd is not None and (latest_deal is None or dd > latest_deal): latest_deal = dd ppm2_values = sorted(adjusted) + # #3452: чем получен коридор — улицей или расширением до города (widen ниже). + # Нужен логу зоны n < estimate_corridor_clamp_min_n: «мало сделок на улице» и + # «мало сделок во всём городе» — разные новости. В DkpCorridor ключ не уходит + # (pydantic игнорирует лишние kwargs), это служебная метка для логов. + scope = "street" # #oblast-D widen: a single street in a small non-EKB town can easily have # <3 (or 0) ДКП deals in the last 12 months even though the CITY overall @@ -2246,6 +2251,7 @@ def _fetch_dkp_corridor( city, ) ppm2_values = sorted(city_adjusted) + scope = "city_wide" # #2846: коридор теперь описывает city-выборку — и возраст обязан # переехать вместе с числами, иначе подпись осталась бы от street- # выборки, которую на витрине уже никто не видит. @@ -2273,6 +2279,7 @@ def _fetch_dkp_corridor( "high_ppm2": int(_percentile(ppm2_values, 0.90)), "period_months": period_months, "latest_deal_date": latest_deal, + "scope": scope, } @@ -5186,6 +5193,24 @@ async def estimate_quality( # 6. Сохраняем в trade_in_estimates estimate_id = uuid4() + + # #3452: коридор ДКП показан, но его ценовые страховки выключены — сделок + # меньше порога доверия (тот же estimate_corridor_clamp_min_n гейтит и + # soft-кламп headline, и radius-floor; гейт Tier C порога не имеет вовсе). + # Зона n=3..9 на экране неотличима от работающего коридора, поэтому + # попадание в неё пишется явной строкой: число оценок за сутки — + # `docker logs tradein-backend --since 24h 2>&1 | grep -c corridor_advisory_zone`. + # Ровно одна строка на оценку: GET-rehydrate сюда не заходит и счёт не двоит. + if dkp_corridor is not None and dkp_corridor.advisory_only: + logger.info( + "corridor_advisory_zone #3452: id=%s n=%d min_n=%d scope=%s city=%s", + estimate_id, + dkp_corridor.count, + settings.estimate_corridor_clamp_min_n, + (dkp_raw or {}).get("scope", "unknown"), + target_city or "unknown", + ) + now = datetime.now(tz=UTC) expires_at = now + timedelta(hours=settings.trade_in_estimate_retention_hours) diff --git a/tradein-mvp/backend/tests/test_3452_corridor_advisory_zone.py b/tradein-mvp/backend/tests/test_3452_corridor_advisory_zone.py new file mode 100644 index 00000000..afcdabf7 --- /dev/null +++ b/tradein-mvp/backend/tests/test_3452_corridor_advisory_zone.py @@ -0,0 +1,210 @@ +"""#3452: зона n = 3..9 — коридор ДКП виден, но цену по нему не поправляют. + +Два порога на одну выборку. Показ коридора открывается с трёх сделок +(`DKP_CORRIDOR_CITY_WIDE_MIN_N`, ниже — city-wide widen), а обе ценовые страховки +по коридору — soft-кламп headline сверху и radius-floor снизу — гейтятся +`estimate_corridor_clamp_min_n` (10). Между ними лежит зона, где коридор +существует, показывается и участвует в fallback-путях, а цену не держит; на +экране это неотличимо от работающего коридора. PR #3445 (снятие предиката +`d.rooms`) переносит в эту зону реальных клиентов: замерено 248 → 3 и 72 → 8 +сделок. + +Решение — advisory-only: порог показа НЕ поднимаем (это отняло бы у клиента +информацию), кламп по трём сделкам НЕ включаем (он был бы хуже своего +отсутствия), но зона помечается в ответе (`DkpCorridor.advisory_only`) и пишется +в лог маркером `corridor_advisory_zone`. + +Тесты ПО ЗНАЧЕНИЮ: прогоняется настоящий estimate_quality с коридором, ПОТОЛОК +которого заведомо ниже медианы аналогов, — то есть кламп прижал бы headline, +если бы ему позволил порог. Проверяется не только метка, но и факт: в зоне цена +НЕ прижата, выше порога — прижата. Захардкоженный флаг (константой в любую +сторону) роняет один из двух тестов, снятое условие клампа — тоже. +""" + +from __future__ import annotations + +import logging +import os +from datetime import UTC, datetime +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +import anyio +import pytest + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +from app.core.config import settings +from app.services.estimator import DKP_CORRIDOR_CITY_WIDE_MIN_N + +# Медиана аналогов заведомо выше потолка коридора × (1 + slack) — кламп, если он +# включён порогом, ОБЯЗАН сработать и прижать headline к cap. +_ANALOG_PPM2 = 200_000.0 +_CORRIDOR_HIGH_PPM2 = 100_000 +_MARKER = "corridor_advisory_zone" + + +def _cap_ppm2() -> float: + """Потолок клампа: corridor_high × (1 + slack) — см. _apply_corridor_clamp.""" + return _CORRIDOR_HIGH_PPM2 * (1.0 + settings.estimate_corridor_clamp_slack) + + +def _make_listing(*, price_per_m2: float, area_m2: float = 50.0) -> dict[str, Any]: + return { + "source": "cian", + "source_url": "https://cian.ru/offer/1", + "address": "ЕКБ, ул. Учителей, 18", + "lat": 56.838, + "lon": 60.595, + "rooms": 2, + "area_m2": area_m2, + "floor": 5, + "total_floors": 16, + "price_rub": price_per_m2 * area_m2, + "price_per_m2": price_per_m2, + "listing_date": datetime(2026, 5, 1), + "days_on_market": 10, + "photo_urls": [], + "scraped_at": datetime(2026, 5, 20, tzinfo=UTC), + "distance_m": 150.0, + "relevance_score": 0.1, + } + + +def _make_geo(): + from app.services.geocoder import GeocodeResult + + return GeocodeResult( + lat=56.838, + lon=60.595, + full_address="Свердловская обл., Екатеринбург, ул. Учителей, 18", + provider="nominatim", + ) + + +def _make_payload(): + from app.schemas.trade_in import TradeInEstimateInput + + return TradeInEstimateInput( + address="ЕКБ, ул. Учителей, 18", + area_m2=50.0, + rooms=2, + floor=5, + total_floors=16, + ) + + +def _corridor(count: int) -> dict[str, Any]: + """Коридор из `count` сделок с потолком ниже медианы аналогов.""" + return { + "count": count, + "low_ppm2": 80_000, + "median_ppm2": 90_000, + "high_ppm2": _CORRIDOR_HIGH_PPM2, + "period_months": 12, + "latest_deal_date": None, + "scope": "street", + } + + +def _run_estimate(dkp_raw: dict[str, Any]) -> Any: + from app.services.estimator import estimate_quality + + # Шесть объявлений — выше HEADLINE_LISTINGS_MIN_N: иначе срабатывает + # #oblast-E и headline уступается коридору (тогда проверялся бы не кламп, + # а deals-fallback, у которого свой порог DEALS_HEADLINE_FALLBACK_MIN_N=3). + analogs = [ + _make_listing(price_per_m2=_ANALOG_PPM2 + delta) + for delta in (-10_000, -5_000, 0.0, 0.0, 5_000, 10_000) + ] + db = MagicMock() + payload = _make_payload() + + async def _run() -> Any: + with ( + patch("app.services.estimator.geocode", new=AsyncMock(return_value=_make_geo())), + patch("app.services.estimator.dadata_clean_address", new=AsyncMock(return_value=None)), + patch("app.services.estimator.match_house_readonly", return_value=None), + patch("app.services.estimator.get_house_metadata", new=AsyncMock(return_value=None)), + patch( + "app.services.estimator._fetch_analogs", + return_value=(list(analogs), False, "S"), + ), + patch("app.services.estimator._fetch_deals", return_value=[]), + patch( + "app.services.estimator._get_or_fetch_imv_cached", + new=AsyncMock(return_value=None), + ), + patch( + "app.services.estimator._get_or_fetch_yandex_valuation_cached", + new=AsyncMock(return_value=None), + ), + patch( + "app.services.estimator.estimate_via_cian_valuation", + new=AsyncMock(return_value=None), + ), + patch("app.services.estimator._fetch_dkp_corridor", return_value=dkp_raw), + patch("app.services.estimator._get_asking_sold_ratio", return_value=(None, None)), + ): + return await estimate_quality(payload, db) + + return anyio.run(_run) + + +def test_zone_exists_at_all() -> None: + """Премиса issue: порог показа коридора ниже порога клампа — зона непуста. + + Если пороги когда-нибудь сведут в один, этот тест скажет об этом прямо, + а не оставит два теста ниже молча проверять пустое множество. + """ + assert settings.estimate_corridor_clamp_min_n > DKP_CORRIDOR_CITY_WIDE_MIN_N, ( + "зона 3..9 схлопнулась — пороги показа и клампа сравнялись, " + "advisory-only решение #3452 больше не описывает реальность" + ) + + +def test_corridor_in_zone_is_flagged_and_does_not_clamp( + caplog: pytest.LogCaptureFixture, +) -> None: + """n на единицу ниже порога: метка стоит, headline НЕ прижат, лог написан.""" + n = settings.estimate_corridor_clamp_min_n - 1 + with caplog.at_level(logging.INFO, logger="app.services.estimator"): + est = _run_estimate(_corridor(n)) + + assert est.dkp_corridor is not None, "коридор обязан остаться видимым — порог показа ниже" + assert est.dkp_corridor.count == n + assert est.dkp_corridor.advisory_only is True, ( + f"n={n} < порога {settings.estimate_corridor_clamp_min_n} — коридор справочный, " + "ответ обязан это называть" + ) + # ФАКТ, а не только метка: кламп прижал бы headline к cap, но порог ему не дал. + assert est.median_price_per_m2 > _cap_ppm2(), ( + f"headline={est.median_price_per_m2} ₽/м² не должен быть прижат к " + f"cap={_cap_ppm2():.0f} — при n={n} кламп выключен порогом" + ) + hits = [r for r in caplog.records if _MARKER in r.getMessage()] + assert len(hits) == 1, f"ожидалась ровно одна строка {_MARKER}, получено {len(hits)}" + msg = hits[0].getMessage() + assert f"n={n}" in msg and f"min_n={settings.estimate_corridor_clamp_min_n}" in msg, msg + assert "scope=street" in msg, msg + + +def test_corridor_above_threshold_is_not_flagged_and_clamps( + caplog: pytest.LogCaptureFixture, +) -> None: + """n выше порога: метки нет, headline прижат к потолку коридора, лога нет.""" + n = settings.estimate_corridor_clamp_min_n + 2 + with caplog.at_level(logging.INFO, logger="app.services.estimator"): + est = _run_estimate(_corridor(n)) + + assert est.dkp_corridor is not None + assert est.dkp_corridor.advisory_only is False, ( + f"n={n} >= порога {settings.estimate_corridor_clamp_min_n} — коридор в цене участвует, " + "справочным его называть нельзя" + ) + assert est.median_price_per_m2 <= round(_cap_ppm2()), ( + f"headline={est.median_price_per_m2} ₽/м² обязан быть прижат к cap={_cap_ppm2():.0f}" + ) + assert not [r for r in caplog.records if _MARKER in r.getMessage()], ( + "строка зоны не должна писаться, когда коридор реально клампит" + ) diff --git a/tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx b/tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx index fe1d0ae9..db30df9a 100644 --- a/tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx @@ -14,7 +14,7 @@ import type { AggregatedEstimate, AvitoImvSummary, TradeInEstimateInput, HouseTy import { asHouseType, asRepairState } from "@/types/trade-in"; import { useActiveBrandSlug, useBrand } from "@/lib/useBrand"; import { sourceDot, sourcePublicLabel } from "@/lib/source-registry"; -import { dealsAsOfLabel } from "@/lib/rosreestr"; +import { corridorAdvisoryNote, dealsAsOfLabel } from "@/lib/rosreestr"; import { SHORT_ESTIMATE_DISCLAIMER } from "@/lib/legal-copy"; import { HeroTransparency } from "./HeroTransparency"; @@ -203,6 +203,11 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals // по сегодня», при том что свежайшая пачка Росреестра на проде — I кв. 2026. // null (оценка старше деплоя бэкенда, поля нет) → хвост просто исчезает. const dkpAsOf = dealsAsOfLabel(dkp?.latest_deal_date, "quarter"); + // #3452: коридор собран из малого числа сделок (ниже порога доверия) — рядом + // с числами обязан стоять размер выборки, иначе коридор читается как опора + // расчёта. n_analogs === 0 → headline посчитан по этим же сделкам, а не по + // объявлениям (та же причина, что у analogsCaption ниже) → подпись другая. + const dkpAdvisory = corridorAdvisoryNote(dkp, estimate.n_analogs === 0); // Progressive enrichment state const needsHouseType = estimate.house_type === null; @@ -581,6 +586,7 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals {dkp.median_ppm2.toLocaleString("ru-RU")} ₽/м² · {dkp.count} сд. {dkpAsOf ? ` · ${dkpAsOf}` : ""} + {dkpAdvisory && {dkpAdvisory}} )} @@ -638,6 +644,9 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals {dkp.median_ppm2.toLocaleString("ru-RU")} ₽/м² · {dkp.count} ДКП {dkpAsOf ? ` · ${dkpAsOf}` : ""} + {dkpAdvisory && ( +