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] @computed_field # type: ignore[prop-decorator]
@property @property
def advisory_only(self) -> bool: def advisory_only(self) -> bool:
"""#3452: True = коридор показан, но в цену не пошёл — сделок меньше порога. """#3452: True = сделок меньше порога, ценовые страховки коридора выключены.
Порог один и тот же (`estimate_corridor_clamp_min_n`) у обоих ценовых Порог один и тот же (`estimate_corridor_clamp_min_n`) у обоих СТРАХОВОЧНЫХ
путей коридора: soft-кламп headline и radius-floor (estimator.py). Ниже путей коридора: soft-кламп headline сверху и radius-floor снизу
него коридор всё ещё виден клиенту и участвует в fallback-путях, но (estimator.py). Ниже него коридор всё ещё виден клиенту, но не держит
ценовой страховки по нему нет зона n=3..9 на экране была неотличима от цену зона n=3..9 на экране была неотличима от работающей.
работающей. Производное от count, поэтому верно во ВСЕХ конструкторах
DkpCorridor автоматически (POST /estimate и GET-rehydrate) и не ВНИМАНИЕ, поле НЕ значит «коридор в цену не вошёл»: гейт Tier C
дублирует порог вторым числом. (#1795 шаг 3) сравнивает якорь с потолком коридора БЕЗ порога вообще, и
deals-headline-fallback берёт медиану коридора начиная с трёх сделок.
Потребителю (витрине) поэтому корректно говорить про РАЗМЕР ВЫБОРКИ, а
не про то, что цену коридор не трогал.
Производное от count, поэтому верно во ВСЕХ конструкторах DkpCorridor
автоматически (POST /estimate и GET-rehydrate) и не дублирует порог
вторым числом.
""" """
from app.core.config import settings # локально: schemas остаётся import-light from app.core.config import settings # локально: schemas остаётся import-light

View file

@ -5194,11 +5194,12 @@ async def estimate_quality(
# 6. Сохраняем в trade_in_estimates # 6. Сохраняем в trade_in_estimates
estimate_id = uuid4() estimate_id = uuid4()
# #3452: коридор ДКП показан, но в цену не пошёл — сделок меньше порога # #3452: коридор ДКП показан, но его ценовые страховки выключены — сделок
# доверия (тот же estimate_corridor_clamp_min_n гейтит и soft-кламп headline, # меньше порога доверия (тот же estimate_corridor_clamp_min_n гейтит и
# и radius-floor). Зона n=3..9 на экране неотличима от работающего коридора, # soft-кламп headline, и radius-floor; гейт Tier C порога не имеет вовсе).
# поэтому попадание в неё пишется явной строкой: число оценок за сутки — # Зона n=3..9 на экране неотличима от работающего коридора, поэтому
# `docker logs <backend> --since 24h | grep -c corridor_advisory_zone`. # попадание в неё пишется явной строкой: число оценок за сутки —
# `docker logs tradein-backend --since 24h 2>&1 | grep -c corridor_advisory_zone`.
# Ровно одна строка на оценку: GET-rehydrate сюда не заходит и счёт не двоит. # Ровно одна строка на оценку: GET-rehydrate сюда не заходит и счёт не двоит.
if dkp_corridor is not None and dkp_corridor.advisory_only: if dkp_corridor is not None and dkp_corridor.advisory_only:
logger.info( logger.info(

View file

@ -203,11 +203,10 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals
// по сегодня», при том что свежайшая пачка Росреестра на проде — I кв. 2026. // по сегодня», при том что свежайшая пачка Росреестра на проде — I кв. 2026.
// null (оценка старше деплоя бэкенда, поля нет) → хвост просто исчезает. // null (оценка старше деплоя бэкенда, поля нет) → хвост просто исчезает.
const dkpAsOf = dealsAsOfLabel(dkp?.latest_deal_date, "quarter"); const dkpAsOf = dealsAsOfLabel(dkp?.latest_deal_date, "quarter");
// #3452: коридор ниже порога доверия — цену по нему не поправляли (ни клампом // #3452: коридор собран из малого числа сделок (ниже порога доверия) — рядом
// сверху, ни floor'ом снизу). Рядом с числами обязан стоять этот факт, иначе // с числами обязан стоять размер выборки, иначе коридор читается как опора
// они читаются как участвующие в расчёте. // расчёта. n_analogs === 0 → headline посчитан по этим же сделкам, а не по
// n_analogs === 0 → headline посчитан по сделкам ДКП, а не по объявлениям // объявлениям (та же причина, что у analogsCaption ниже) → подпись другая.
// (та же причина, что у analogsCaption ниже) — подпись тогда не рисуется.
const dkpAdvisory = corridorAdvisoryNote(dkp, estimate.n_analogs === 0); const dkpAdvisory = corridorAdvisoryNote(dkp, estimate.n_analogs === 0);
// Progressive enrichment state // Progressive enrichment state

View file

@ -609,10 +609,10 @@ interface DealTier {
/** #2846: «по I кв. 2026» — as-of ТОГО набора сделок, который дал числа выше. */ /** #2846: «по I кв. 2026» — as-of ТОГО набора сделок, который дал числа выше. */
asOf: string | null; asOf: string | null;
/** /**
* #3452: подпись «справочно», если числа плитки пришли из ДКП-коридора, по * #3452: подпись про размер выборки, если числа плитки пришли из ДКП-коридора
* которому цену не поправляли (сделок меньше порога доверия). null у прочих * с малым числом сделок (ниже порога доверия). null у прочих веток:
* веток: street-deals и actual_deals в кламп не ходят вообще, и обещать там * street-deals и actual_deals в ценовые пути коридора не ходят вообще, и
* «страховка выключена» было бы новостью не про эту выборку. * подпись про его выборку была бы новостью не про эти числа.
*/ */
advisoryNote: string | null; advisoryNote: string | null;
} }
@ -695,7 +695,7 @@ function resolveDealTier(
// числа карточки (медиана/границы) — из коридора, у которого своя выборка. // числа карточки (медиана/границы) — из коридора, у которого своя выборка.
asOf: dealsAsOfLabel(c.latest_deal_date, "quarter"), asOf: dealsAsOfLabel(c.latest_deal_date, "quarter"),
// n_analogs === 0 → headline и есть медиана этого коридора (dealsOnlyPrice // n_analogs === 0 → headline и есть медиана этого коридора (dealsOnlyPrice
// ниже по файлу разбирает тот же случай) — подписи тогда нет. // ниже по файлу разбирает тот же случай) — подпись тогда прямее.
advisoryNote: corridorAdvisoryNote(c, e.n_analogs === 0), advisoryNote: corridorAdvisoryNote(c, e.n_analogs === 0),
}; };
} }
@ -1243,7 +1243,7 @@ export function mapResultPanel(
.join(" · ") .join(" · ")
: "—", : "—",
bars: dealTier ? dealTier.bars : [], bars: dealTier ? dealTier.bars : [],
// #3452: «справочно» — числа из коридора, по которому цену не поправляли. // #3452: подпись про размер выборки коридора (страховки по нему выключены).
note: dealTier?.advisoryNote ?? undefined, note: dealTier?.advisoryNote ?? undefined,
nav: 1, nav: 1,
}, },

View file

@ -39,14 +39,20 @@ export function dealsAsOfLabel(
} }
/** /**
* #3452: «справочно» под коридором ДКП сделок меньше порога доверия. * #3452: подпись под коридором ДКП, собранным из малого числа сделок.
* *
* Порог (`estimate_corridor_clamp_min_n`) считает бэкенд и отдаёт готовый * Порог (`estimate_corridor_clamp_min_n`) считает бэкенд и отдаёт готовый
* advisory_only: ниже него коридор ВИДЕН, но цену по нему не поправляют ни * advisory_only: ниже него у коридора выключены ОБЕ его ценовые страховки
* кламп headline сверху, ни radius-floor снизу. Молчание тут выдавало бы * кламп headline сверху и radius-floor снизу. Молчание тут выдавало бы
* выключенную страховку за работающую: на экране такой коридор ничем не * выключенную страховку за работающую: на экране такой коридор ничем не
* отличается от того, что цену действительно держит. * отличается от того, что цену действительно держит.
* *
* Текст намеренно говорит про ДАННЫЕ (сделок мало), а НЕ про алгоритм
* («цену по ним не корректировали»). Второе было бы неправдой: гейт Tier C
* (#1795 шаг 3, estimator.py) сравнивает якорь с потолком коридора БЕЗ порога
* min_n коридор из пяти сделок там способен уронить headline на треть.
* Утверждение про размер выборки истинно во всех достижимых состояниях.
*
* null (флага нет оценка старше деплоя, либо сделок хватает) подписи нет. * null (флага нет оценка старше деплоя, либо сделок хватает) подписи нет.
*/ */
export function corridorAdvisoryNote( export function corridorAdvisoryNote(
@ -54,13 +60,13 @@ export function corridorAdvisoryNote(
headlineFromDeals: boolean, headlineFromDeals: boolean,
): string | null { ): string | null {
if (dkp?.advisory_only !== true) return null; if (dkp?.advisory_only !== true) return null;
// Молчим, когда headline ПОСТРОЕН из этого же коридора (deals-fallback, // headline ПОСТРОЕН из этого же коридора (deals-fallback, n_analogs === 0 —
// n_analogs === 0 — похожих объявлений не нашлось): там показанная цена и // похожих объявлений рядом не нашлось): коридор тут не сбоку от цены, он и
// есть медиана этих сделок, и «оценку по ним не корректировали» было бы // есть цена, и клиент обязан знать, на скольких сделках она стоит.
// ложью в другую сторону. Метка и лог на бэкенде при этом остаются: факт if (headlineFromDeals) {
// «страховка клампом выключена» от источника headline не зависит. return `оценка построена на этих сделках — их всего ${dkp.count}`;
if (headlineFromDeals) return null; }
return "справочно: мало сделок — оценку по ним не корректировали"; return `справочно: сделок мало (${dkp.count}) — коридор ориентировочный`;
} }
export async function openRosreestrWithAddress(address: string): Promise<void> { export async function openRosreestrWithAddress(address: string): Promise<void> {