fix(tradein): коридор ДКП с малым числом сделок помечен справочным
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 11s
CI Trade-In / browser-tests (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 / frontend-checks (pull_request) Successful in 1m16s
CI Trade-In / backend-tests (pull_request) Successful in 5m32s

Показ коридора открывается с трёх сделок (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 <noreply@anthropic.com>
This commit is contained in:
bot-backend 2026-09-12 13:42:43 +05:00
parent 9fa01e7ebe
commit b71f3f9957
7 changed files with 317 additions and 5 deletions

View file

@ -158,10 +158,14 @@ class DkpCorridor(BaseModel):
"""Коридор реальных ДКП-сделок Росреестра для target (#652).
Источник: `deals` (source='rosreestr', ДКП-only), агрегированные по улице +
rooms + площади ±15% за период. ADVISORY: показывается как тонкая референсная
линия «коридор реальных сделок: XY млн»; если итоговая медиана /м² выходит
за [low,high]×slack добавляется текстовая пометка. НЕ хард-клампит оценку.
rooms + площади ±15% за период. Показывается как тонкая референсная линия
«коридор реальных сделок: XY млн»; если итоговая медиана /м² выходит за
[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).

View file

@ -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 <backend> --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)

View file

@ -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()], (
"строка зоны не должна писаться, когда коридор реально клампит"
)

View file

@ -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}` : ""}
</span>
{dkpAdvisory && <span className="bench-chip__sub">{dkpAdvisory}</span>}
</div>
)}
</div>
@ -638,6 +645,9 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals
{dkp.median_ppm2.toLocaleString("ru-RU")} /м² · {dkp.count} <abbr title="Договор купли-продажи (реальные сделки Росреестра)">ДКП</abbr>
{dkpAsOf ? ` · ${dkpAsOf}` : ""}
</div>
{dkpAdvisory && (
<div style={{ marginTop: 6, fontSize: 12, color: "var(--muted)" }}>{dkpAdvisory}</div>
)}
</div>
)}
</div>

View file

@ -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,
},
];

View file

@ -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<void> {
try {
await navigator.clipboard.writeText(address);

View file

@ -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 {