From 2fe34ac78567aa0622f2bd812e86f57e8f9decdd Mon Sep 17 00:00:00 2001 From: bot-backend Date: Fri, 26 Jun 2026 16:07:12 +0000 Subject: [PATCH] =?UTF-8?q?fix(estimator):=20ghost-anchor=20=E2=80=94=20co?= =?UTF-8?q?nfidence=3D'low'=20when=20radius=20analogs=20empty=20(#1871)=20?= =?UTF-8?q?(#1920)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tradein-mvp/backend/app/services/estimator.py | 18 ++++ ...stimator_ghost_anchor_radius_empty_1871.py | 100 ++++++++++++++++++ 2 files changed, 118 insertions(+) create mode 100644 tradein-mvp/backend/tests/test_estimator_ghost_anchor_radius_empty_1871.py diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index 15acb693..c3eb9d1f 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -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 diff --git a/tradein-mvp/backend/tests/test_estimator_ghost_anchor_radius_empty_1871.py b/tradein-mvp/backend/tests/test_estimator_ghost_anchor_radius_empty_1871.py new file mode 100644 index 00000000..d95dd8a7 --- /dev/null +++ b/tradein-mvp/backend/tests/test_estimator_ghost_anchor_radius_empty_1871.py @@ -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"]))