From 668ac406317537625546241abc0a6587c4eec097 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 12 Sep 2026 14:36:29 +0500 Subject: [PATCH] =?UTF-8?q?fix(tradein):=20=D0=BF=D0=BE=D0=B4=D0=BF=D0=B8?= =?UTF-8?q?=D1=81=D1=8C=20=D0=BA=D0=BE=D1=80=D0=B8=D0=B4=D0=BE=D1=80=D0=B0?= =?UTF-8?q?=20=D0=B3=D0=BE=D0=B2=D0=BE=D1=80=D0=B8=D1=82=20=D0=BF=D1=80?= =?UTF-8?q?=D0=BE=20=D0=B2=D1=8B=D0=B1=D0=BE=D1=80=D0=BA=D1=83,=20=D0=B0?= =?UTF-8?q?=20=D0=BD=D0=B5=20=D0=BF=D1=80=D0=BE=20=D0=B0=D0=BB=D0=B3=D0=BE?= =?UTF-8?q?=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 {