From 45a9db2f254370f6539730b236ad56f702833d4c Mon Sep 17 00:00:00 2001 From: lekss361 Date: Sat, 30 May 2026 11:54:16 +0300 Subject: [PATCH] fix(tradein): flag coarse-geo estimates as low-confidence (#693) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Когда 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 --- tradein-mvp/backend/app/services/estimator.py | 26 +++++++ .../tests/test_same_building_anchor.py | 77 ++++++++++++++++++- 2 files changed, 102 insertions(+), 1 deletion(-) diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index a86c84f6..c7f5442c 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -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 К/м². diff --git a/tradein-mvp/backend/tests/test_same_building_anchor.py b/tradein-mvp/backend/tests/test_same_building_anchor.py index ed92620d..e22d57f8 100644 --- a/tradein-mvp/backend/tests/test_same_building_anchor.py +++ b/tradein-mvp/backend/tests/test_same_building_anchor.py @@ -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 "")