fix(tradein): подпись коридора говорит про выборку, а не про алгоритм
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 10s
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 1m9s
CI Trade-In / backend-tests (pull_request) Successful in 5m21s

Ревью #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 <noreply@anthropic.com>
This commit is contained in:
bot-backend 2026-09-12 14:36:29 +05:00
parent b71f3f9957
commit 668ac40631
5 changed files with 47 additions and 34 deletions

View file

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

View file

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

View file

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

View file

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

View file

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