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() {