fix(tradein/estimator): ghost-anchor invariant guard — n_analogs=0 forces low (audit #1871 P1.2) #1874

Merged
bot-backend merged 1 commit from audit/1871-p1-2-ghost-anchor-guard into main 2026-06-23 06:06:47 +00:00
5 changed files with 174 additions and 7 deletions

View file

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

View file

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

View file

@ -76,7 +76,10 @@ export default function HistoryPage() {
<td style={{ padding: 8, textAlign: "right" }}>
{Number(r.median_price).toLocaleString("ru-RU")}
</td>
<td style={{ padding: 8 }}>{r.confidence}</td>
<td style={{ padding: 8 }}>
{/* #1871 P1.2 — ghost-anchor defensive depth: n_analogs=0 → low */}
{r.n_analogs === 0 ? "low" : r.confidence}
</td>
</tr>
))}
</tbody>

View file

@ -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<string>("");
const [enrichRepairState, setEnrichRepairState] = useState<string>("");
// Фото первого аналога с картинкой — вместо пустого серого плейсхолдера.

View file

@ -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() —