fix(tradein): flag coarse-geo estimates as low-confidence (#693)

Когда DaData/геокод резолвит адрес лишь до уровня города/населённого пункта
(qc_geo>=2 — центроид, не дом/улица), радиусный поиск брал аналоги по случайной
окрестности и выдавал уверенную medium-оценку, хотя адрес по сути не найден.

Добавлен консервативный downgrade: confidence→low + флаг «оценка ориентировочная»,
ТОЛЬКО при позитивном coarse-сигнале (dadata.qc_geo>=2) И несработавшем
same-building anchor (anchor_tier is None — путь #691 с реальными комплами дома
не трогаем) И median_price>0. Без dadata/qc_geo — не понижаем (не фабрикуем).

Тесты: coarse+no-anchor→low+флаг; coarse+anchor→не понижен (#691 защищён);
precise→без изменений; dadata=None→без ложного downgrade. 41 passed.

Closes #693
This commit is contained in:
lekss361 2026-05-30 11:54:16 +03:00
parent f04728297b
commit 45a9db2f25
2 changed files with 102 additions and 1 deletions

View file

@ -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 К/м².

View file

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