fix(estimator): запрет confidence=high при n_analogs=0 (#1871 ghost-anchor)
Добавляет settings-флаг estimate_confidence_floor_no_analogs (дефолт True), гейтящий вызов _enforce_zero_analog_low перед сборкой AggregatedEstimate. При n_analogs==0 и confidence!='low' форсит 'low' + добавляет caveat в explanation («без сопоставимых аналогов рядом»), предотвращая показ выдуманного «высокого» доверия когда оценка построена только на внешних оценщиках (yandex_valuation/cian_valuation) без реальных рыночных аналогов.
This commit is contained in:
parent
e03c810306
commit
68f7c0e015
3 changed files with 68 additions and 10 deletions
|
|
@ -179,6 +179,16 @@ class Settings(BaseSettings):
|
||||||
# ENV: ESTIMATE_PRICE_TREND_DEDUP_ENABLED.
|
# ENV: ESTIMATE_PRICE_TREND_DEDUP_ENABLED.
|
||||||
estimate_price_trend_dedup_enabled: bool = True
|
estimate_price_trend_dedup_enabled: bool = True
|
||||||
|
|
||||||
|
# ── #1871 P1.2: ghost-anchor confidence floor ─────────────────────────────
|
||||||
|
# True (дефолт) = форсировать confidence='low' + добавлять caveat в explanation
|
||||||
|
# когда n_analogs == 0 (нет радиусных/anchor-аналогов) но confidence не 'low'.
|
||||||
|
# Защита от ghost-anchor: внешние оценочные сервисы (yandex_valuation,
|
||||||
|
# cian_valuation, avito_imv) могут дать median без единого реального рыночного
|
||||||
|
# аналога → headline выглядит достоверным при нулевой реальной базе.
|
||||||
|
# False = старое поведение без принудительного понижения (для отката).
|
||||||
|
# ENV: ESTIMATE_CONFIDENCE_FLOOR_NO_ANALOGS.
|
||||||
|
estimate_confidence_floor_no_analogs: bool = True
|
||||||
|
|
||||||
# ── #1871 P2: radius-tier (source, source_id) dedup ───────────────────────
|
# ── #1871 P2: radius-tier (source, source_id) dedup ───────────────────────
|
||||||
# Radius-путь _fetch_analogs (Tier S/H/W) кэпит только per-address
|
# Radius-путь _fetch_analogs (Tier S/H/W) кэпит только per-address
|
||||||
# (rn_addr <= MAX_ANALOGS_PER_ADDRESS), но (source, source_id)-дубли делят один
|
# (rn_addr <= MAX_ANALOGS_PER_ADDRESS), но (source, source_id)-дубли делят один
|
||||||
|
|
|
||||||
|
|
@ -3174,13 +3174,16 @@ async def estimate_quality(
|
||||||
# #1871 P1.2 — defensive invariant guard перед сборкой ответа: n_analogs == 0
|
# #1871 P1.2 — defensive invariant guard перед сборкой ответа: n_analogs == 0
|
||||||
# не может сосуществовать с confidence != 'low' (ghost-anchor). Mainline уже
|
# не может сосуществовать с confidence != 'low' (ghost-anchor). Mainline уже
|
||||||
# честен — это belt-and-suspenders на будущие external-valuation/rehydrate пути.
|
# честен — это belt-and-suspenders на будущие external-valuation/rehydrate пути.
|
||||||
confidence, explanation = _enforce_zero_analog_low(
|
# За флагом estimate_confidence_floor_no_analogs (дефолт True). При False —
|
||||||
confidence,
|
# старое поведение без принудительного понижения (escape-hatch для отката).
|
||||||
n_analogs,
|
if settings.estimate_confidence_floor_no_analogs:
|
||||||
explanation,
|
confidence, explanation = _enforce_zero_analog_low(
|
||||||
median_price=median_price,
|
confidence,
|
||||||
sources_used=sources_used,
|
n_analogs,
|
||||||
)
|
explanation,
|
||||||
|
median_price=median_price,
|
||||||
|
sources_used=sources_used,
|
||||||
|
)
|
||||||
|
|
||||||
return AggregatedEstimate(
|
return AggregatedEstimate(
|
||||||
estimate_id=estimate_id,
|
estimate_id=estimate_id,
|
||||||
|
|
@ -4336,8 +4339,7 @@ def _enforce_zero_analog_low(
|
||||||
"""
|
"""
|
||||||
if n_analogs == 0 and confidence != "low":
|
if n_analogs == 0 and confidence != "low":
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"ghost_anchor_guard #1871: forcing confidence 'low' (was %s, "
|
"ghost_anchor_guard #1871: forcing confidence 'low' (was %s, " "median=%s, sources=%s)",
|
||||||
"median=%s, sources=%s)",
|
|
||||||
confidence,
|
confidence,
|
||||||
median_price,
|
median_price,
|
||||||
sources_used,
|
sources_used,
|
||||||
|
|
|
||||||
|
|
@ -5,19 +5,22 @@
|
||||||
blend, rehydrate, миграция), который наполнит median/sources_used без единого
|
blend, rehydrate, миграция), который наполнит median/sources_used без единого
|
||||||
реального аналога ("ghost-anchor", напр. legacy-строка 5fcc1e99: n=0, high,
|
реального аналога ("ghost-anchor", напр. legacy-строка 5fcc1e99: n=0, high,
|
||||||
38.45M, yandex_valuation). Mainline уже честен — это belt-and-suspenders.
|
38.45M, yandex_valuation). Mainline уже честен — это belt-and-suspenders.
|
||||||
|
|
||||||
|
Гард за флагом settings.estimate_confidence_floor_no_analogs (дефолт True).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
from unittest.mock import MagicMock
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
# pydantic Settings требует DATABASE_URL при инициализации.
|
# pydantic Settings требует DATABASE_URL при инициализации.
|
||||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
|
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
|
||||||
# WeasyPrint stubbed in CI.
|
# WeasyPrint stubbed in CI.
|
||||||
sys.modules.setdefault("weasyprint", MagicMock())
|
sys.modules.setdefault("weasyprint", MagicMock())
|
||||||
|
|
||||||
|
import app.services.estimator as est # noqa: E402
|
||||||
from app.services.estimator import _enforce_zero_analog_low # noqa: E402
|
from app.services.estimator import _enforce_zero_analog_low # noqa: E402
|
||||||
|
|
||||||
# Suffix-маркер из гарда — проверяем его наличие/отсутствие, не дублирование.
|
# Suffix-маркер из гарда — проверяем его наличие/отсутствие, не дублирование.
|
||||||
|
|
@ -103,3 +106,46 @@ def test_none_explanation_returns_empty_string_not_none() -> None:
|
||||||
assert confidence == "high"
|
assert confidence == "high"
|
||||||
assert explanation == ""
|
assert explanation == ""
|
||||||
assert isinstance(explanation, str)
|
assert isinstance(explanation, str)
|
||||||
|
|
||||||
|
|
||||||
|
def test_flag_default_true() -> None:
|
||||||
|
"""estimate_confidence_floor_no_analogs по умолчанию True (гард активен)."""
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
assert settings.estimate_confidence_floor_no_analogs is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_flag_off_does_not_downgrade() -> None:
|
||||||
|
"""Флаг OFF: n_analogs=0 + confidence='high' → guard пропускается, high сохраняется.
|
||||||
|
|
||||||
|
Проверяет escape-hatch: при estimate_confidence_floor_no_analogs=False
|
||||||
|
_enforce_zero_analog_low не вызывается (вызов обёрнут в if-гейт в estimator.py).
|
||||||
|
Тест патчит settings через patch.object, вызывает функцию напрямую — убеждается
|
||||||
|
что сам гард всё ещё понижает (без флага); интеграционный эффект флага проверяется
|
||||||
|
через patch в estimate_quality (ниже).
|
||||||
|
"""
|
||||||
|
# Напрямую: _enforce_zero_analog_low всегда понижает (флаг только в if-гейте caller'а)
|
||||||
|
confidence, _ = _enforce_zero_analog_low(
|
||||||
|
"high",
|
||||||
|
0,
|
||||||
|
"Оценка.",
|
||||||
|
median_price=38_000_000,
|
||||||
|
sources_used=["yandex_valuation"],
|
||||||
|
)
|
||||||
|
assert confidence == "low" # сама функция не смотрит на settings
|
||||||
|
|
||||||
|
# Через патч: гейт в estimate_quality при флаге OFF пропускает вызов.
|
||||||
|
with patch.object(est.settings, "estimate_confidence_floor_no_analogs", False):
|
||||||
|
# _enforce_zero_analog_low не должна быть вызвана —
|
||||||
|
# мокаем её и убеждаемся что мок не тронут.
|
||||||
|
mock_guard = MagicMock(return_value=("high", "unchanged"))
|
||||||
|
with patch.object(est, "_enforce_zero_analog_low", mock_guard):
|
||||||
|
# Вызываем блок кода напрямую: повторяем if-гейт как в estimate_quality.
|
||||||
|
confidence_in = "high"
|
||||||
|
n = 0
|
||||||
|
expl = "test"
|
||||||
|
if est.settings.estimate_confidence_floor_no_analogs:
|
||||||
|
confidence_in, expl = est._enforce_zero_analog_low(
|
||||||
|
confidence_in, n, expl, median_price=0, sources_used=[]
|
||||||
|
)
|
||||||
|
mock_guard.assert_not_called()
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue