fix(tradein): flag coarse-geo estimates as low-confidence (#693) #707
2 changed files with 102 additions and 1 deletions
|
|
@ -1903,6 +1903,32 @@ async def estimate_quality(
|
|||
" Оценка ниже коридора реальных сделок Росреестра по улице."
|
||||
)
|
||||
|
||||
# ── #693: coarse-geo downgrade ──────────────────────────────────────────
|
||||
# Когда DaData дала только грубую точность (settlement/city/region/unknown,
|
||||
# qc_geo >= 2), PostGIS-радиус крутится вокруг центроида НП и собирает
|
||||
# аналоги из случайных частей города → уверенная «medium» вводит в
|
||||
# заблуждение. Помечаем как ориентировочную (low). КОНСЕРВАТИВНО:
|
||||
# 1) есть позитивный сигнал грубости (dadata.qc_geo >= 2);
|
||||
# 2) якорь «тот же дом» НЕ сработал (anchor_tier is None) — иначе оценка
|
||||
# стоит на реальных комплах дома (#691 path) и геоцентроид не важен;
|
||||
# 3) есть радиусное число (median_price > 0) — что квалифицировать.
|
||||
if (
|
||||
dadata is not None
|
||||
and dadata.qc_geo is not None
|
||||
and dadata.qc_geo >= 2
|
||||
and anchor_tier is None
|
||||
and median_price > 0
|
||||
):
|
||||
_coarse_label = {2: "населённого пункта", 3: "города", 4: "региона"}.get(
|
||||
dadata.qc_geo, "населённого пункта"
|
||||
)
|
||||
confidence = "low"
|
||||
explanation = (explanation or "") + (
|
||||
f" Адрес определён лишь до уровня {_coarse_label} — точные координаты "
|
||||
"дома найти не удалось, поэтому оценка ориентировочная (аналоги взяты "
|
||||
"по широкой окрестности)."
|
||||
)
|
||||
|
||||
# 5. Deals — ДКП-only sales (вторичка) из rosreestr_deals.
|
||||
# Importer фильтрует doc_type='ДКП' (PR-A 2026-05-24), ДДУ застройщиков
|
||||
# исключены — больше не скёюят median вторички ~110-120 К/м².
|
||||
|
|
|
|||
|
|
@ -378,6 +378,7 @@ def _run_estimate(
|
|||
ratio_tuple: tuple[float | None, str | None] = (0.92, "per_rooms"),
|
||||
payload=None,
|
||||
radius_analogs: list[dict[str, Any]] | None = None,
|
||||
dadata_result: Any | None = None,
|
||||
):
|
||||
"""estimate_quality со всеми I/O застабленными; _fetch_anchor_comps форсирован.
|
||||
|
||||
|
|
@ -396,7 +397,10 @@ def _run_estimate(
|
|||
with (
|
||||
patch.object(settings, "estimate_same_building_anchor_enabled", flag_enabled),
|
||||
patch("app.services.estimator.geocode", new=AsyncMock(return_value=_make_fake_geo())),
|
||||
patch("app.services.estimator.dadata_clean_address", new=AsyncMock(return_value=None)),
|
||||
patch(
|
||||
"app.services.estimator.dadata_clean_address",
|
||||
new=AsyncMock(return_value=dadata_result),
|
||||
),
|
||||
patch("app.services.estimator.match_house_readonly", return_value=None),
|
||||
patch("app.services.estimator.get_house_metadata", new=AsyncMock(return_value=None)),
|
||||
patch(
|
||||
|
|
@ -667,3 +671,74 @@ def test_anchor_floor_weight_neutral_when_data_missing() -> None:
|
|||
)
|
||||
assert a is not None and b is not None
|
||||
assert abs(a["anchor_ppm2"] - b["anchor_ppm2"]) < 1.0
|
||||
|
||||
|
||||
# ── #693: coarse-geo (centroid) confidence downgrade ─────────────────────────
|
||||
|
||||
_APPROX_FLAG = "оценка ориентировочная"
|
||||
|
||||
|
||||
def _make_dadata(qc_geo: int | None):
|
||||
"""Минимальный DadataAddressResult с заданным qc_geo (прочие поля None/пусто)."""
|
||||
from app.services.dadata import DadataAddressResult
|
||||
|
||||
return DadataAddressResult(
|
||||
canonical_address=None,
|
||||
house_cadnum=None,
|
||||
house_fias_id=None,
|
||||
lat=None,
|
||||
lon=None,
|
||||
qc_geo=qc_geo,
|
||||
qc_house=None,
|
||||
kladr_id=None,
|
||||
okato=None,
|
||||
oktmo=None,
|
||||
metro=[],
|
||||
raw={},
|
||||
)
|
||||
|
||||
|
||||
def test_estimate_coarse_geo_no_anchor_downgrades_to_low() -> None:
|
||||
"""#693: qc_geo=3 (город) + НЕТ якоря, но радиусные аналоги есть (median>0) →
|
||||
confidence='low' + флаг «ориентировочной» оценки (центроид НП, не дом)."""
|
||||
est = _run_estimate(
|
||||
anchor_comps=[],
|
||||
anchor_tier=None,
|
||||
dadata_result=_make_dadata(qc_geo=3),
|
||||
)
|
||||
assert est.median_price_rub > 0 # радиусное число есть → было что квалифицировать
|
||||
assert est.confidence == "low"
|
||||
assert _APPROX_FLAG in est.confidence_explanation
|
||||
assert "города" in est.confidence_explanation
|
||||
|
||||
|
||||
def test_estimate_coarse_geo_with_anchor_not_downgraded() -> None:
|
||||
"""#693 protect #691: qc_geo=3 грубый, НО якорь дома сработал (tier=A) →
|
||||
confidence НЕ даунгрейдится (оценка стоит на реальных комплах дома)."""
|
||||
est = _run_estimate(
|
||||
anchor_comps=_SB_COMPS_PREMIUM,
|
||||
anchor_tier="A",
|
||||
dadata_result=_make_dadata(qc_geo=3),
|
||||
)
|
||||
# Якорь сам выставил confidence — не "low" от #693-даунгрейда, и без флага.
|
||||
assert _APPROX_FLAG not in (est.confidence_explanation or "")
|
||||
|
||||
|
||||
def test_estimate_precise_geo_no_downgrade() -> None:
|
||||
"""#693: qc_geo=0 (дом, точно) + нет якоря → НЕТ даунгрейда (точный адрес)."""
|
||||
est = _run_estimate(
|
||||
anchor_comps=[],
|
||||
anchor_tier=None,
|
||||
dadata_result=_make_dadata(qc_geo=0),
|
||||
)
|
||||
assert _APPROX_FLAG not in (est.confidence_explanation or "")
|
||||
|
||||
|
||||
def test_estimate_no_dadata_no_false_downgrade() -> None:
|
||||
"""#693: dadata=None (нет сигнала) → НЕ фабрикуем даунгрейд (no false positive)."""
|
||||
est = _run_estimate(
|
||||
anchor_comps=[],
|
||||
anchor_tier=None,
|
||||
dadata_result=None,
|
||||
)
|
||||
assert _APPROX_FLAG not in (est.confidence_explanation or "")
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue