From b71f3f99577852c23cdcb0a72f8d6a09f854992f Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 12 Sep 2026 13:42:43 +0500 Subject: [PATCH 1/2] =?UTF-8?q?fix(tradein):=20=D0=BA=D0=BE=D1=80=D0=B8?= =?UTF-8?q?=D0=B4=D0=BE=D1=80=20=D0=94=D0=9A=D0=9F=20=D1=81=20=D0=BC=D0=B0?= =?UTF-8?q?=D0=BB=D1=8B=D0=BC=20=D1=87=D0=B8=D1=81=D0=BB=D0=BE=D0=BC=20?= =?UTF-8?q?=D1=81=D0=B4=D0=B5=D0=BB=D0=BE=D0=BA=20=D0=BF=D0=BE=D0=BC=D0=B5?= =?UTF-8?q?=D1=87=D0=B5=D0=BD=20=D1=81=D0=BF=D1=80=D0=B0=D0=B2=D0=BE=D1=87?= =?UTF-8?q?=D0=BD=D1=8B=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Показ коридора открывается с трёх сделок (DKP_CORRIDOR_CITY_WIDE_MIN_N), а обе ценовые страховки по нему — soft-кламп headline сверху и radius-floor снизу — включаются с десяти (estimate_corridor_clamp_min_n). В зоне n=3..9 коридор существует, показывается и участвует в fallback-путях, но цену не держит, и на экране это неотличимо от работающего коридора. PR #3445 (снятие предиката d.rooms) переносит туда реальных клиентов: замерено 248 → 3 и 72 → 8 сделок. Решение — advisory-only. Порог показа не поднят (это отняло бы у клиента информацию), кламп по трём сделкам не включён (был бы хуже своего отсутствия), но зона теперь названа вслух: - DkpCorridor.advisory_only — computed-поле от count и ЕДИНСТВЕННОГО порога estimate_corridor_clamp_min_n, так что верно во всех конструкторах коридора (POST /estimate и GET-rehydrate) и не дублирует порог вторым числом; - лог INFO с маркером corridor_advisory_zone (n, порог, scope street/city_wide, id оценки) — одна строка на оценку, считается за сутки одним grep -c; - _fetch_dkp_corridor отдаёт служебный ключ scope: «мало сделок на улице» и «мало сделок во всём городе» — разные новости, и лог обязан их различать; - на экране (v1 hero + плитка ДКП в v2) подпись «справочно: мало сделок — оценку по ним не корректировали». Подпись молчит, когда headline ПОСТРОЕН из этого же коридора (n_analogs = 0, deals-fallback): там показанная цена и есть медиана этих сделок, и подпись была бы ложью в другую сторону. Тесты по значению (test_3452_corridor_advisory_zone.py) гоняют настоящий estimate_quality с коридором, потолок которого заведомо ниже медианы аналогов: в зоне headline НЕ прижат и метка стоит, выше порога — прижат к cap и метки нет. Захардкоженный флаг в любую сторону и снятый порог клампа роняют тесты (проверено руками). Closes #3452 Co-Authored-By: Claude Opus 5 --- tradein-mvp/backend/app/schemas/trade_in.py | 27 ++- tradein-mvp/backend/app/services/estimator.py | 24 ++ .../tests/test_3452_corridor_advisory_zone.py | 210 ++++++++++++++++++ .../src/components/trade-in/HeroSummary.tsx | 12 +- .../src/components/trade-in/v2/mappers.ts | 16 +- tradein-mvp/frontend/src/lib/rosreestr.ts | 27 +++ tradein-mvp/frontend/src/types/trade-in.ts | 6 + 7 files changed, 317 insertions(+), 5 deletions(-) create mode 100644 tradein-mvp/backend/tests/test_3452_corridor_advisory_zone.py diff --git a/tradein-mvp/backend/app/schemas/trade_in.py b/tradein-mvp/backend/app/schemas/trade_in.py index 4517b236..7eb7c00d 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,23 @@ 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). Ниже + него коридор всё ещё виден клиенту и участвует в fallback-путях, но + ценовой страховки по нему нет — зона n=3..9 на экране была неотличима от + работающей. Производное от 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..8e78b1fe 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,23 @@ async def estimate_quality( # 6. Сохраняем в trade_in_estimates estimate_id = uuid4() + + # #3452: коридор ДКП показан, но в цену не пошёл — сделок меньше порога + # доверия (тот же estimate_corridor_clamp_min_n гейтит и soft-кламп headline, + # и radius-floor). Зона n=3..9 на экране неотличима от работающего коридора, + # поэтому попадание в неё пишется явной строкой: число оценок за сутки — + # `docker logs --since 24h | 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..20f5b3ed 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,12 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals // по сегодня», при том что свежайшая пачка Росреестра на проде — I кв. 2026. // null (оценка старше деплоя бэкенда, поля нет) → хвост просто исчезает. const dkpAsOf = dealsAsOfLabel(dkp?.latest_deal_date, "quarter"); + // #3452: коридор ниже порога доверия — цену по нему не поправляли (ни клампом + // сверху, ни floor'ом снизу). Рядом с числами обязан стоять этот факт, иначе + // они читаются как участвующие в расчёте. + // n_analogs === 0 → headline посчитан по сделкам ДКП, а не по объявлениям + // (та же причина, что у analogsCaption ниже) — подпись тогда не рисуется. + const dkpAdvisory = corridorAdvisoryNote(dkp, estimate.n_analogs === 0); // Progressive enrichment state const needsHouseType = estimate.house_type === null; @@ -581,6 +587,7 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals {dkp.median_ppm2.toLocaleString("ru-RU")} ₽/м² · {dkp.count} сд. {dkpAsOf ? ` · ${dkpAsOf}` : ""} + {dkpAdvisory && {dkpAdvisory}} )} @@ -638,6 +645,9 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals {dkp.median_ppm2.toLocaleString("ru-RU")} ₽/м² · {dkp.count} ДКП {dkpAsOf ? ` · ${dkpAsOf}` : ""} + {dkpAdvisory && ( +
{dkpAdvisory}
+ )} )} diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts b/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts index 82f8fe25..6b62bacc 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts +++ b/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts @@ -76,7 +76,7 @@ import type { } from "./types"; import { tokens } from "./tokens"; import { sourcePublicLabel } from "@/lib/source-registry"; -import { dealsAsOfLabel } from "@/lib/rosreestr"; +import { corridorAdvisoryNote, dealsAsOfLabel } from "@/lib/rosreestr"; // Total number of source slots — meta "N / M". Single source of truth is // SOURCE_SLOTS.length (defined with the array below as TOTAL_SOURCES). @@ -608,6 +608,13 @@ interface DealTier { bars: number[]; /** #2846: «по I кв. 2026» — as-of ТОГО набора сделок, который дал числа выше. */ asOf: string | null; + /** + * #3452: подпись «справочно», если числа плитки пришли из ДКП-коридора, по + * которому цену не поправляли (сделок меньше порога доверия). null у прочих + * веток: street-deals и actual_deals в кламп не ходят вообще, и обещать там + * «страховка выключена» было бы новостью не про эту выборку. + */ + advisoryNote: string | null; } /** Свежайшая дата среди лотов + её точность (лоты не всегда отсортированы). */ @@ -668,6 +675,7 @@ function resolveDealTier( // sd.deals — top-10 из ORDER BY deal_date DESC по всем sd.count сделкам, // так что максимум по ним = максимум по всей выборке, не по показанным. asOf: dealsAsOfLabel(...newestLot(sd.deals)), + advisoryNote: null, }; } const area = e.area_m2; @@ -686,6 +694,9 @@ function resolveDealTier( // Не из actual_deals: гистограмма заимствует их только как форму, а // числа карточки (медиана/границы) — из коридора, у которого своя выборка. asOf: dealsAsOfLabel(c.latest_deal_date, "quarter"), + // n_analogs === 0 → headline и есть медиана этого коридора (dealsOnlyPrice + // ниже по файлу разбирает тот же случай) — подписи тогда нет. + advisoryNote: corridorAdvisoryNote(c, e.n_analogs === 0), }; } if (e.actual_deals.length > 0) { @@ -703,6 +714,7 @@ function resolveDealTier( // Fix #1/#8: same high-outlier guard as the ads card (see above). bars: bins8(guardPriceOutliers(ppms).clean), asOf: dealsAsOfLabel(...newestLot(e.actual_deals)), + advisoryNote: null, }; } return null; @@ -1231,6 +1243,8 @@ export function mapResultPanel( .join(" · ") : "—", bars: dealTier ? dealTier.bars : [], + // #3452: «справочно» — числа из коридора, по которому цену не поправляли. + note: dealTier?.advisoryNote ?? undefined, nav: 1, }, ]; diff --git a/tradein-mvp/frontend/src/lib/rosreestr.ts b/tradein-mvp/frontend/src/lib/rosreestr.ts index 6c2aa52e..b2e431d2 100644 --- a/tradein-mvp/frontend/src/lib/rosreestr.ts +++ b/tradein-mvp/frontend/src/lib/rosreestr.ts @@ -1,3 +1,5 @@ +import type { DkpCorridor } from "@/types/trade-in"; + export const ROSREESTR_REQUEST_URL = "https://rosreestr.gov.ru/eservices/request_info_from_egrn/"; @@ -36,6 +38,31 @@ export function dealsAsOfLabel( return `по ${ROMAN_QUARTER[Math.floor(d.getUTCMonth() / 3)]} кв. ${d.getUTCFullYear()}`; } +/** + * #3452: «справочно» под коридором ДКП — сделок меньше порога доверия. + * + * Порог (`estimate_corridor_clamp_min_n`) считает бэкенд и отдаёт готовый + * advisory_only: ниже него коридор ВИДЕН, но цену по нему не поправляют — ни + * кламп headline сверху, ни radius-floor снизу. Молчание тут выдавало бы + * выключенную страховку за работающую: на экране такой коридор ничем не + * отличается от того, что цену действительно держит. + * + * null (флага нет — оценка старше деплоя, либо сделок хватает) → подписи нет. + */ +export function corridorAdvisoryNote( + dkp: DkpCorridor | null | undefined, + headlineFromDeals: boolean, +): string | null { + if (dkp?.advisory_only !== true) return null; + // Молчим, когда headline ПОСТРОЕН из этого же коридора (deals-fallback, + // n_analogs === 0 — похожих объявлений не нашлось): там показанная цена и + // есть медиана этих сделок, и «оценку по ним не корректировали» было бы + // ложью в другую сторону. Метка и лог на бэкенде при этом остаются: факт + // «страховка клампом выключена» от источника headline не зависит. + if (headlineFromDeals) return null; + return "справочно: мало сделок — оценку по ним не корректировали"; +} + export async function openRosreestrWithAddress(address: string): Promise { try { await navigator.clipboard.writeText(address); diff --git a/tradein-mvp/frontend/src/types/trade-in.ts b/tradein-mvp/frontend/src/types/trade-in.ts index 6bcbd92c..be0846ec 100644 --- a/tradein-mvp/frontend/src/types/trade-in.ts +++ b/tradein-mvp/frontend/src/types/trade-in.ts @@ -167,6 +167,12 @@ export interface DkpCorridor { // «2026-01-01» читается как «I кв. 2026», а не как 1 января. optional: оценки, // посчитанные до деплоя бэкенда, поля не содержат → подпись просто не рисуется. latest_deal_date?: string | null; + // #3452: true = сделок меньше порога доверия (бэкенд считает порог сам, + // estimate_corridor_clamp_min_n) → коридор показан, но цену по нему НЕ + // поправляли: ни кламп сверху, ни floor снизу. Заголовок «advisory» у всего + // коридора не про это — при count >= порога он в цену как раз идёт. + // optional: оценки, посчитанные до деплоя бэкенда, поля не содержат. + advisory_only?: boolean; } export interface AggregatedEstimate { -- 2.45.3 From 668ac406317537625546241abc0a6587c4eec097 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 12 Sep 2026 14:36:29 +0500 Subject: [PATCH 2/2] =?UTF-8?q?fix(tradein):=20=D0=BF=D0=BE=D0=B4=D0=BF?= =?UTF-8?q?=D0=B8=D1=81=D1=8C=20=D0=BA=D0=BE=D1=80=D0=B8=D0=B4=D0=BE=D1=80?= =?UTF-8?q?=D0=B0=20=D0=B3=D0=BE=D0=B2=D0=BE=D1=80=D0=B8=D1=82=20=D0=BF?= =?UTF-8?q?=D1=80=D0=BE=20=D0=B2=D1=8B=D0=B1=D0=BE=D1=80=D0=BA=D1=83,=20?= =?UTF-8?q?=D0=B0=20=D0=BD=D0=B5=20=D0=BF=D1=80=D0=BE=20=D0=B0=D0=BB=D0=B3?= =?UTF-8?q?=D0=BE=D1=80=D0=B8=D1=82=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ревью #3462 поймало ложь в микрокопии: «оценку по ним не корректировали» утверждает про АЛГОРИТМ то, чего код не гарантирует. Порог estimate_corridor_clamp_min_n гейтит только две страховки — кламп headline и radius-floor. Третий ценовой путь, гейт Tier C (#1795 шаг 3, estimator.py), сравнивает якорь с потолком коридора БЕЗ порога вообще: коридор из пяти сделок там способен уронить headline на треть (воспроизведено ревьюером: якорь Tier C 300 000 ₽/м², с коридором 200 000 против 300 500 без него). Плюс deals-headline-fallback берёт медиану коридора начиная с трёх сделок. Формулировка переписана на утверждение о ДАННЫХ — оно истинно во всех достижимых состояниях: «справочно: сделок мало (N) — коридор ориентировочный». Ветка «объявлений рядом нет» (n_analogs = 0) больше не молчит: раньше там возвращался null, и клиент не узнавал, что вся его цена стоит на трёх сделках. Теперь — «оценка построена на этих сделках — их всего N». Докстринги advisory_only в схеме и комментарий у лога тоже перестали обещать «коридор в цену не пошёл»: поле значит ровно «страховки выключены». Co-Authored-By: Claude Opus 5 --- tradein-mvp/backend/app/schemas/trade_in.py | 23 ++++++++++------ tradein-mvp/backend/app/services/estimator.py | 11 ++++---- .../src/components/trade-in/HeroSummary.tsx | 9 +++---- .../src/components/trade-in/v2/mappers.ts | 12 ++++----- tradein-mvp/frontend/src/lib/rosreestr.ts | 26 ++++++++++++------- 5 files changed, 47 insertions(+), 34 deletions(-) diff --git a/tradein-mvp/backend/app/schemas/trade_in.py b/tradein-mvp/backend/app/schemas/trade_in.py index 7eb7c00d..00d6c074 100644 --- a/tradein-mvp/backend/app/schemas/trade_in.py +++ b/tradein-mvp/backend/app/schemas/trade_in.py @@ -190,15 +190,22 @@ class DkpCorridor(BaseModel): @computed_field # type: ignore[prop-decorator] @property def advisory_only(self) -> bool: - """#3452: True = коридор показан, но в цену не пошёл — сделок меньше порога. + """#3452: True = сделок меньше порога, ценовые страховки коридора выключены. - Порог один и тот же (`estimate_corridor_clamp_min_n`) у обоих ценовых - путей коридора: soft-кламп headline и radius-floor (estimator.py). Ниже - него коридор всё ещё виден клиенту и участвует в fallback-путях, но - ценовой страховки по нему нет — зона n=3..9 на экране была неотличима от - работающей. Производное от count, поэтому верно во ВСЕХ конструкторах - DkpCorridor автоматически (POST /estimate и GET-rehydrate) и не - дублирует порог вторым числом. + Порог один и тот же (`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 diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index 8e78b1fe..ac9089f6 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -5194,11 +5194,12 @@ async def estimate_quality( # 6. Сохраняем в trade_in_estimates estimate_id = uuid4() - # #3452: коридор ДКП показан, но в цену не пошёл — сделок меньше порога - # доверия (тот же estimate_corridor_clamp_min_n гейтит и soft-кламп headline, - # и radius-floor). Зона n=3..9 на экране неотличима от работающего коридора, - # поэтому попадание в неё пишется явной строкой: число оценок за сутки — - # `docker logs --since 24h | grep -c corridor_advisory_zone`. + # #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( diff --git a/tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx b/tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx index 20f5b3ed..db30df9a 100644 --- a/tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx @@ -203,11 +203,10 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals // по сегодня», при том что свежайшая пачка Росреестра на проде — I кв. 2026. // null (оценка старше деплоя бэкенда, поля нет) → хвост просто исчезает. const dkpAsOf = dealsAsOfLabel(dkp?.latest_deal_date, "quarter"); - // #3452: коридор ниже порога доверия — цену по нему не поправляли (ни клампом - // сверху, ни floor'ом снизу). Рядом с числами обязан стоять этот факт, иначе - // они читаются как участвующие в расчёте. - // n_analogs === 0 → headline посчитан по сделкам ДКП, а не по объявлениям - // (та же причина, что у analogsCaption ниже) — подпись тогда не рисуется. + // #3452: коридор собран из малого числа сделок (ниже порога доверия) — рядом + // с числами обязан стоять размер выборки, иначе коридор читается как опора + // расчёта. n_analogs === 0 → headline посчитан по этим же сделкам, а не по + // объявлениям (та же причина, что у analogsCaption ниже) → подпись другая. const dkpAdvisory = corridorAdvisoryNote(dkp, estimate.n_analogs === 0); // Progressive enrichment state diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts b/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts index 6b62bacc..e907526a 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts +++ b/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts @@ -609,10 +609,10 @@ interface DealTier { /** #2846: «по I кв. 2026» — as-of ТОГО набора сделок, который дал числа выше. */ asOf: string | null; /** - * #3452: подпись «справочно», если числа плитки пришли из ДКП-коридора, по - * которому цену не поправляли (сделок меньше порога доверия). null у прочих - * веток: street-deals и actual_deals в кламп не ходят вообще, и обещать там - * «страховка выключена» было бы новостью не про эту выборку. + * #3452: подпись про размер выборки, если числа плитки пришли из ДКП-коридора + * с малым числом сделок (ниже порога доверия). null у прочих веток: + * street-deals и actual_deals в ценовые пути коридора не ходят вообще, и + * подпись про его выборку была бы новостью не про эти числа. */ advisoryNote: string | null; } @@ -695,7 +695,7 @@ function resolveDealTier( // числа карточки (медиана/границы) — из коридора, у которого своя выборка. asOf: dealsAsOfLabel(c.latest_deal_date, "quarter"), // n_analogs === 0 → headline и есть медиана этого коридора (dealsOnlyPrice - // ниже по файлу разбирает тот же случай) — подписи тогда нет. + // ниже по файлу разбирает тот же случай) — подпись тогда прямее. advisoryNote: corridorAdvisoryNote(c, e.n_analogs === 0), }; } @@ -1243,7 +1243,7 @@ export function mapResultPanel( .join(" · ") : "—", bars: dealTier ? dealTier.bars : [], - // #3452: «справочно» — числа из коридора, по которому цену не поправляли. + // #3452: подпись про размер выборки коридора (страховки по нему выключены). note: dealTier?.advisoryNote ?? undefined, nav: 1, }, diff --git a/tradein-mvp/frontend/src/lib/rosreestr.ts b/tradein-mvp/frontend/src/lib/rosreestr.ts index b2e431d2..3e92bb8a 100644 --- a/tradein-mvp/frontend/src/lib/rosreestr.ts +++ b/tradein-mvp/frontend/src/lib/rosreestr.ts @@ -39,14 +39,20 @@ export function dealsAsOfLabel( } /** - * #3452: «справочно» под коридором ДКП — сделок меньше порога доверия. + * #3452: подпись под коридором ДКП, собранным из малого числа сделок. * * Порог (`estimate_corridor_clamp_min_n`) считает бэкенд и отдаёт готовый - * advisory_only: ниже него коридор ВИДЕН, но цену по нему не поправляют — ни - * кламп headline сверху, ни radius-floor снизу. Молчание тут выдавало бы + * advisory_only: ниже него у коридора выключены ОБЕ его ценовые страховки — + * кламп headline сверху и radius-floor снизу. Молчание тут выдавало бы * выключенную страховку за работающую: на экране такой коридор ничем не * отличается от того, что цену действительно держит. * + * Текст намеренно говорит про ДАННЫЕ (сделок мало), а НЕ про алгоритм + * («цену по ним не корректировали»). Второе было бы неправдой: гейт Tier C + * (#1795 шаг 3, estimator.py) сравнивает якорь с потолком коридора БЕЗ порога + * min_n — коридор из пяти сделок там способен уронить headline на треть. + * Утверждение про размер выборки истинно во всех достижимых состояниях. + * * null (флага нет — оценка старше деплоя, либо сделок хватает) → подписи нет. */ export function corridorAdvisoryNote( @@ -54,13 +60,13 @@ export function corridorAdvisoryNote( headlineFromDeals: boolean, ): string | null { if (dkp?.advisory_only !== true) return null; - // Молчим, когда headline ПОСТРОЕН из этого же коридора (deals-fallback, - // n_analogs === 0 — похожих объявлений не нашлось): там показанная цена и - // есть медиана этих сделок, и «оценку по ним не корректировали» было бы - // ложью в другую сторону. Метка и лог на бэкенде при этом остаются: факт - // «страховка клампом выключена» от источника headline не зависит. - if (headlineFromDeals) return null; - return "справочно: мало сделок — оценку по ним не корректировали"; + // headline ПОСТРОЕН из этого же коридора (deals-fallback, n_analogs === 0 — + // похожих объявлений рядом не нашлось): коридор тут не сбоку от цены, он и + // есть цена, и клиент обязан знать, на скольких сделках она стоит. + if (headlineFromDeals) { + return `оценка построена на этих сделках — их всего ${dkp.count}`; + } + return `справочно: сделок мало (${dkp.count}) — коридор ориентировочный`; } export async function openRosreestrWithAddress(address: string): Promise { -- 2.45.3