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
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:
parent
e03c810306
commit
2fe34ac785
2 changed files with 118 additions and 0 deletions
|
|
@ -2492,6 +2492,24 @@ async def estimate_quality(
|
||||||
# которых построена оценка (= показываемый analogs, capped 10). Для anchor-
|
# которых построена оценка (= показываемый analogs, capped 10). Для anchor-
|
||||||
# пути это и есть «полное число найденных» по контракту n_analogs (#698).
|
# пути это и есть «полное число найденных» по контракту n_analogs (#698).
|
||||||
n_analogs = anchor["n"]
|
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) ──
|
# ── #1871 P2: split-дома wide-corridor disclosure (за флагом, OFF) ──
|
||||||
# Tier A матчит по address-regex (намеренно НЕ house_id — дом дробится
|
# Tier A матчит по address-regex (намеренно НЕ house_id — дом дробится
|
||||||
# на несколько house_id). На split-доме разной этажности comp_min..max
|
# на несколько 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