From 99736b6e324efc4c7f786ffe8f13eb50c241da3a Mon Sep 17 00:00:00 2001 From: Light1YT Date: Fri, 26 Jun 2026 21:01:34 +0500 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)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #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. --- 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"])) -- 2.45.3