fix(estimator): ghost-anchor — confidence='low' when radius analogs empty (#1871) (#1920)
All checks were successful
Deploy Trade-In / changes (push) Successful in 10s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 1m26s
Deploy Trade-In / deploy (push) Successful in 48s
Deploy Trade-In / build-backend (push) Successful in 58s

This commit is contained in:
bot-backend 2026-06-26 16:07:12 +00:00
parent e03c810306
commit 2fe34ac785
2 changed files with 118 additions and 0 deletions

View file

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

View file

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