diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index de181775..2776e13d 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -3152,6 +3152,17 @@ async def estimate_quality( else: api_analog_tier = None + # #1871 P1.2 — defensive invariant guard перед сборкой ответа: n_analogs == 0 + # не может сосуществовать с confidence != 'low' (ghost-anchor). Mainline уже + # честен — это belt-and-suspenders на будущие external-valuation/rehydrate пути. + confidence, explanation = _enforce_zero_analog_low( + confidence, + n_analogs, + explanation, + median_price=median_price, + sources_used=sources_used, + ) + return AggregatedEstimate( estimate_id=estimate_id, median_price_rub=median_price, @@ -4247,6 +4258,42 @@ def _percentile(sorted_values: list[float], p: float) -> float: return sorted_values[lo] + (sorted_values[hi] - sorted_values[lo]) * frac +def _enforce_zero_analog_low( + confidence: str, + n_analogs: int, + explanation: str | None, + *, + median_price: int | float | None = None, + sources_used: list[str] | None = None, +) -> tuple[str, str]: + """#1871 P1.2 — defensive invariant: n_analogs == 0 МОЖЕТ означать только 'low'. + + Гард ловит любой будущий путь (external-valuation blend, rehydrate, миграция), + который наполнит median/sources_used без единого реального аналога ("ghost-anchor"). + Текущий mainline уже честен (_compute_confidence → 'low' при median=0, #755 cap) — + это belt-and-suspenders, не load-bearing. Не вшивает fake median: реальное число + от внешнего сервиса остаётся, мы лишь понижаем confidence + добавляем caveat. + + Возвращает (confidence, explanation): при n_analogs==0 и confidence!='low' — + форсит 'low' + caveat-suffix; иначе значения проходят без изменений. + """ + if n_analogs == 0 and confidence != "low": + logger.warning( + "ghost_anchor_guard #1871: forcing confidence 'low' (was %s, " + "median=%s, sources=%s)", + confidence, + median_price, + sources_used, + ) + explanation_suffix = ( + " Оценка построена без сопоставимых аналогов рядом " + "(использованы только внешние оценочные сервисы) — точность " + "ориентировочная, рекомендуем дополнительную проверку." + ) + return "low", (explanation or "") + explanation_suffix + return confidence, explanation or "" + + def _compute_confidence( n_analogs: int, median_ppm2: float, diff --git a/tradein-mvp/backend/tests/test_estimator_ghost_anchor_1871.py b/tradein-mvp/backend/tests/test_estimator_ghost_anchor_1871.py new file mode 100644 index 00000000..31c1c753 --- /dev/null +++ b/tradein-mvp/backend/tests/test_estimator_ghost_anchor_1871.py @@ -0,0 +1,105 @@ +"""Unit tests for #1871 P1.2 — ghost-anchor invariant guard. + +Инвариант: n_analogs == 0 МОЖЕТ означать только confidence='low'. +Гард `_enforce_zero_analog_low` ловит любой будущий путь (external-valuation +blend, rehydrate, миграция), который наполнит median/sources_used без единого +реального аналога ("ghost-anchor", напр. legacy-строка 5fcc1e99: n=0, high, +38.45M, yandex_valuation). Mainline уже честен — это belt-and-suspenders. +""" + +from __future__ import annotations + +import os +import sys +from unittest.mock import MagicMock + +# pydantic Settings требует DATABASE_URL при инициализации. +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db") +# WeasyPrint stubbed in CI. +sys.modules.setdefault("weasyprint", MagicMock()) + +from app.services.estimator import _enforce_zero_analog_low # noqa: E402 + +# Suffix-маркер из гарда — проверяем его наличие/отсутствие, не дублирование. +_CAVEAT_MARKER = "без сопоставимых аналогов рядом" + + +def test_zero_analogs_forces_low_confidence() -> None: + """Ghost-симуляция: n_analogs=0 но confidence='high', median>0 → low + caveat. + + Воспроизводит legacy ghost 5fcc1e99 (n=0, high, 38.45M, yandex_valuation): + confidence должен быть форсирован в 'low', caveat-suffix добавлен. + """ + confidence, explanation = _enforce_zero_analog_low( + "high", + 0, + "Оценка по внешним сервисам.", + median_price=38_450_000, + sources_used=["yandex_valuation"], + ) + + assert confidence == "low" + assert _CAVEAT_MARKER in explanation + # Исходный текст explanation сохранён (caveat дописан, не затёр). + assert "Оценка по внешним сервисам." in explanation + + +def test_zero_analogs_medium_also_forced_low() -> None: + """n_analogs=0 + confidence='medium' → low + caveat (любой не-low форсится).""" + confidence, explanation = _enforce_zero_analog_low( + "medium", + 0, + None, + median_price=10_000_000, + sources_used=["cian_valuation"], + ) + + assert confidence == "low" + assert _CAVEAT_MARKER in explanation + + +def test_zero_analogs_already_low_no_duplicate_caveat() -> None: + """n_analogs=0 + confidence='low' → остаётся low, caveat НЕ дублируется.""" + original = "Не найдено аналогов — попробуйте уточнить адрес." + confidence, explanation = _enforce_zero_analog_low( + "low", + 0, + original, + median_price=0, + sources_used=[], + ) + + assert confidence == "low" + assert explanation == original + assert _CAVEAT_MARKER not in explanation + + +def test_nonzero_analogs_high_unchanged() -> None: + """n_analogs=5 + confidence='high' → проходит без изменений (честный путь).""" + original = "Найдено 5 аналогов из 4 разных адресов." + confidence, explanation = _enforce_zero_analog_low( + "high", + 5, + original, + median_price=12_000_000, + sources_used=["avito", "cian"], + ) + + assert confidence == "high" + assert explanation == original + assert _CAVEAT_MARKER not in explanation + + +def test_none_explanation_returns_empty_string_not_none() -> None: + """explanation=None должен нормализоваться в строку (контракт schema — str).""" + confidence, explanation = _enforce_zero_analog_low( + "high", + 7, + None, + median_price=9_000_000, + sources_used=["avito"], + ) + + assert confidence == "high" + assert explanation == "" + assert isinstance(explanation, str) diff --git a/tradein-mvp/frontend/src/app/history/page.tsx b/tradein-mvp/frontend/src/app/history/page.tsx index f2c0621b..e31319d5 100644 --- a/tradein-mvp/frontend/src/app/history/page.tsx +++ b/tradein-mvp/frontend/src/app/history/page.tsx @@ -76,7 +76,10 @@ export default function HistoryPage() { {Number(r.median_price).toLocaleString("ru-RU")} - {r.confidence} + + {/* #1871 P1.2 — ghost-anchor defensive depth: n_analogs=0 → low */} + {r.n_analogs === 0 ? "low" : r.confidence} + ))} diff --git a/tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx b/tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx index cfe1d3ea..61346bf5 100644 --- a/tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx @@ -7,7 +7,7 @@ * Показывает медиану + достоверность CV + параметры объекта + 2 ценовых бара (объявления / сделки). */ import { useState } from "react"; -import type { AggregatedEstimate, TradeInEstimateInput, HouseType, RepairState } from "@/types/trade-in"; +import type { AggregatedEstimate, TradeInEstimateInput, HouseType, RepairState, ConfidenceLevel } from "@/types/trade-in"; import { asHouseType, asRepairState } from "@/types/trade-in"; import { useActiveBrandSlug, useBrand } from "@/lib/useBrand"; import { HeroTransparency } from "./HeroTransparency"; @@ -79,7 +79,14 @@ function isMockAddress(estimate: AggregatedEstimate): boolean { export function HeroSummary({ estimate, input, onResubmit, isResubmitting = false }: Props) { const cv = calcCv(estimate); - const conf = CONF_LABELS[estimate.confidence] ?? CONF_LABELS.low; + // Defensive depth (audit #1871 P1.2): ghost-anchor guard. Если legacy-строка / + // rehydrate / контракт-drift отдаст confidence='high' при n_analogs=0, UI должен + // показывать «низкая», а не «высокая · 0 объектов». Бэкенд уже не отдаёт high при + // n_analogs=0, это второй слой защиты для отображения (сырое значение в analytics + // не нужно). Caveat-текст приходит из estimate.confidence_explanation (backend). + const effectiveConfidence: ConfidenceLevel = + estimate.n_analogs === 0 ? "low" : estimate.confidence; + const conf = CONF_LABELS[effectiveConfidence] ?? CONF_LABELS.low; // Brand-aware PDF + lead-тема. useActiveBrandSlug (merged) reuse, brand name из /brand. const activeBrandSlug = useActiveBrandSlug(); const { data: brand } = useBrand(); @@ -135,7 +142,7 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals const needsHouseType = estimate.house_type === null; const needsRepairState = estimate.repair_state === null; const showEnrichment = - (needsHouseType || needsRepairState) && estimate.confidence !== "low"; + (needsHouseType || needsRepairState) && effectiveConfidence !== "low"; const [enrichHouseType, setEnrichHouseType] = useState(""); const [enrichRepairState, setEnrichRepairState] = useState(""); // Фото первого аналога с картинкой — вместо пустого серого плейсхолдера. diff --git a/tradein-mvp/frontend/src/components/trade-in/HeroTransparency.tsx b/tradein-mvp/frontend/src/components/trade-in/HeroTransparency.tsx index 62f4f8a9..02ee3228 100644 --- a/tradein-mvp/frontend/src/components/trade-in/HeroTransparency.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/HeroTransparency.tsx @@ -18,7 +18,7 @@ */ import { useState, useEffect } from "react"; -import type { AggregatedEstimate, AnalogTier } from "@/types/trade-in"; +import type { AggregatedEstimate, AnalogTier, ConfidenceLevel } from "@/types/trade-in"; import { API_BASE_URL } from "@/lib/api"; interface Props { @@ -126,7 +126,12 @@ function freshnessFromScraped(estimate: AggregatedEstimate): Date | null { } export function HeroTransparency({ estimate, brandSlug, brandName }: Props) { - const band = CONF_BAND[estimate.confidence] ?? CONF_BAND.low; + // Defensive depth (audit #1871 P1.2): ghost-anchor guard, как в HeroSummary. + // n_analogs=0 → отображаем «низкая достоверность», даже если контракт-drift + // отдал confidence='high'. Caveat-текст — из confidence_explanation (backend). + const effectiveConfidence: ConfidenceLevel = + estimate.n_analogs === 0 ? "low" : estimate.confidence; + const band = CONF_BAND[effectiveConfidence] ?? CONF_BAND.low; // Задача 1: предпочитаем структурный analog_tier, fallback на эвристику строки. const tier = resolveCompsTier(estimate); // Задача 2: точность адреса в деталях (если задана). @@ -136,7 +141,7 @@ export function HeroTransparency({ estimate, brandSlug, brandName }: Props) { : null; // Предупреждение при low-confidence + приблизительном адресе. const showPrecisionWarning = - estimate.confidence === "low" && estimate.address_precision === "approximate"; + effectiveConfidence === "low" && estimate.address_precision === "approximate"; // SSR-safe: берём last_scraped_at (статичная ISO строка — нет мismatch). // Если его нет, fallback на data_freshness_minutes через Date.now() —