fix(estimator): ghost-anchor — confidence='low' when radius analogs empty (#1871)
#1871 P1 (Ghost-anchor) trust defect: when the same-building anchor replaces the headline estimate, the code sets confidence = anchor["confidence"] and overwrites n_analogs = anchor["n"] (>0). With ZERO radius analogs (listings_clean == []) this yielded confidence='high'/'medium' on an estimate built only on same-building comps (sometimes a single yandex_valuation, e.g. median 38.4M) with no comparable nearby listings. The defensive guard _enforce_zero_analog_low only fires on n_analogs == 0, but n_analogs was already overwritten to anchor["n"] (>0) — so it never caught this path. UI rendered "Высокая · 0 объектов" = fabricated confidence. Fix: inline guard inside the `if anchor is not None:` block, right after n_analogs = anchor["n"]. When listings_clean is empty and confidence != 'low', downgrade to 'low', log a warning, and append an honest caveat to explanation. Sits before _enforce_zero_analog_low and before the result is finalized, so the downgraded confidence + caveat propagate to the response. Test: tests/test_estimator_ghost_anchor_radius_empty_1871.py exercises the full estimate path (via the test_same_building_anchor harness) with a tight-spread anchor (n=5, would be 'high') and empty radius → asserts confidence='low' + caveat present; plus a control that a non-empty radius is not downgraded.
This commit is contained in:
parent
e03c810306
commit
99736b6e32
2 changed files with 118 additions and 0 deletions
|
|
@ -2492,6 +2492,24 @@ async def estimate_quality(
|
|||
# которых построена оценка (= показываемый analogs, capped 10). Для anchor-
|
||||
# пути это и есть «полное число найденных» по контракту n_analogs (#698).
|
||||
n_analogs = anchor["n"]
|
||||
# #1871 P1 (ghost-anchor): same-building anchor заменил headline, но
|
||||
# радиусных аналогов ноль (listings_clean пуст) → нельзя отдавать
|
||||
# высокую/среднюю уверенность. Downgrade до 'low' + честный caveat.
|
||||
# _enforce_zero_analog_low ниже это не ловит: n_analogs уже перезаписан
|
||||
# на anchor["n"] (>0), а не 0.
|
||||
if not listings_clean and confidence != "low":
|
||||
logger.warning(
|
||||
"estimator #1871 ghost-anchor guard: confidence %s→low "
|
||||
"(anchor_n=%s, radius_analogs=0)",
|
||||
confidence,
|
||||
anchor["n"],
|
||||
)
|
||||
confidence = "low"
|
||||
explanation += (
|
||||
" Оценка опирается только на аналоги из того же дома — "
|
||||
"сопоставимых предложений поблизости не найдено, поэтому "
|
||||
"точность ориентировочная."
|
||||
)
|
||||
# ── #1871 P2: split-дома wide-corridor disclosure (за флагом, OFF) ──
|
||||
# Tier A матчит по address-regex (намеренно НЕ house_id — дом дробится
|
||||
# на несколько house_id). На split-доме разной этажности comp_min..max
|
||||
|
|
|
|||
|
|
@ -0,0 +1,100 @@
|
|||
"""#1871 P1 (ghost-anchor) — same-building anchor с пустым радиусом → confidence='low'.
|
||||
|
||||
Прод-дефект «Ghost-anchor»: same-building anchor подменяет headline, но радиусных
|
||||
аналогов ноль (listings_clean пуст, напр. ST_DWithin не нашёл соседей, anchor
|
||||
держится на одном yandex_valuation). Раньше confidence наследовался от anchor
|
||||
('high'/'medium'), а n_analogs перезаписывался на anchor["n"] (>0) — поэтому
|
||||
defensive-гард `_enforce_zero_analog_low` (срабатывает только на n_analogs==0) его
|
||||
НЕ ловил. UI рисовал «Высокая · 0 объектов» = сфабрикованная уверенность.
|
||||
|
||||
Фикс: inline-гард в `if anchor is not None:` блоке понижает confidence до 'low' и
|
||||
дописывает caveat, когда listings_clean пуст. Полный estimate-путь со всеми I/O
|
||||
застаблен через harness из test_same_building_anchor.py (_run_estimate / _make_payload).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
# Settings требует DATABASE_URL при инициализации (fail-fast, C-3).
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
|
||||
|
||||
import pytest
|
||||
|
||||
# Переиспользуем harness _run_estimate / _make_payload из anchor-теста (полный
|
||||
# estimate путь со всеми I/O застабленными).
|
||||
_ANCHOR_TEST = Path(__file__).parent / "test_same_building_anchor.py"
|
||||
_spec = importlib.util.spec_from_file_location("_anchor_harness_ghost", _ANCHOR_TEST)
|
||||
assert _spec is not None and _spec.loader is not None
|
||||
_h = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(_h)
|
||||
|
||||
# Caveat-маркер из inline-гарда estimator.py (#1871 P1).
|
||||
_CAVEAT_MARKER = "сопоставимых предложений поблизости не найдено"
|
||||
|
||||
# 5 same-building комплов с очень tight spread → anchor сам по себе дал бы
|
||||
# confidence='high' (n>=5, CV≈0.005). Это и есть ghost-сценарий: высокая
|
||||
# уверенность на якоре дома при НУЛЕВОМ радиусе.
|
||||
_SB_COMPS_TIGHT: list[dict[str, Any]] = [
|
||||
{"price_per_m2": 300_000, "area_m2": 60.0, "rooms": 2},
|
||||
{"price_per_m2": 302_000, "area_m2": 61.0, "rooms": 2},
|
||||
{"price_per_m2": 298_000, "area_m2": 59.0, "rooms": 2},
|
||||
{"price_per_m2": 301_000, "area_m2": 60.5, "rooms": 2},
|
||||
{"price_per_m2": 299_000, "area_m2": 59.5, "rooms": 2},
|
||||
]
|
||||
|
||||
|
||||
def _payload(): # type: ignore[no-untyped-def]
|
||||
return _h._make_payload(area=60.0, rooms=2)
|
||||
|
||||
|
||||
def test_ghost_anchor_empty_radius_forces_low_confidence() -> None:
|
||||
"""Anchor (tight spread, n=5) + радиус пуст → confidence='low', caveat дописан.
|
||||
|
||||
Без гарда anchor отдал бы 'high' (UI: «Высокая · 0 объектов» = ghost).
|
||||
"""
|
||||
est = _h._run_estimate(
|
||||
anchor_comps=_SB_COMPS_TIGHT,
|
||||
anchor_tier="A",
|
||||
radius_analogs=[],
|
||||
payload=_payload(),
|
||||
)
|
||||
# Anchor построил headline (фикс #691 не фабрикует число из воздуха — anchor реален).
|
||||
assert est.median_price_rub > 0
|
||||
# Ghost-гард понизил confidence до 'low' несмотря на tight-spread anchor.
|
||||
assert est.confidence == "low"
|
||||
# Caveat про «только аналоги из того же дома» дописан.
|
||||
assert _CAVEAT_MARKER in (est.confidence_explanation or "")
|
||||
|
||||
|
||||
def test_ghost_anchor_caveat_appended_not_replacing_anchor_explanation() -> None:
|
||||
"""Caveat дописывается К якорному explanation, не затирая «по N аналогам из дома»."""
|
||||
est = _h._run_estimate(
|
||||
anchor_comps=_SB_COMPS_TIGHT,
|
||||
anchor_tier="A",
|
||||
radius_analogs=[],
|
||||
payload=_payload(),
|
||||
)
|
||||
exp = est.confidence_explanation or ""
|
||||
# Якорный base-текст сохранён (#695) + caveat дописан (#1871 P1).
|
||||
assert "Оценка построена по" in exp
|
||||
assert _CAVEAT_MARKER in exp
|
||||
|
||||
|
||||
def test_anchor_with_radius_analogs_not_downgraded_by_ghost_guard() -> None:
|
||||
"""Контроль: тот же anchor + НЕПУСТОЙ радиус → ghost-гард НЕ срабатывает,
|
||||
caveat отсутствует (честный путь, есть сопоставимые предложения поблизости)."""
|
||||
est = _h._run_estimate(
|
||||
anchor_comps=_SB_COMPS_TIGHT,
|
||||
anchor_tier="A",
|
||||
# radius_analogs=None → harness подставляет _RADIUS_ANALOGS (3 лота).
|
||||
payload=_payload(),
|
||||
)
|
||||
assert _CAVEAT_MARKER not in (est.confidence_explanation or "")
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
raise SystemExit(pytest.main([__file__, "-q"]))
|
||||
Loading…
Add table
Reference in a new issue