fix(tradein/estimator): ghost-anchor invariant guard — n_analogs=0 forces low (audit #1871 P1.2) #1874
5 changed files with 174 additions and 7 deletions
|
|
@ -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,
|
||||
|
|
|
|||
105
tradein-mvp/backend/tests/test_estimator_ghost_anchor_1871.py
Normal file
105
tradein-mvp/backend/tests/test_estimator_ghost_anchor_1871.py
Normal 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)
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>("");
|
||||
// Фото первого аналога с картинкой — вместо пустого серого плейсхолдера.
|
||||
|
|
|
|||
|
|
@ -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() —
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue