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 {