diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index 55a834bb..58fb01c5 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -1288,6 +1288,38 @@ def _compute_same_building_anchor( } +# ── #693 prod-fix: coarse-geocode detector (DaData-independent) ───────────── +# Дом всегда оканчивается номером (1-3 цифры, опц. литера корпуса). Centroid +# города/региона его НЕ содержит. Прод-сигнал грубости геокода, работающий БЕЗ +# DaData: на проде DaData может быть off (token не сконфигурирован) → dadata.qc_geo +# всегда None → старый гейт #707 (условный на qc_geo>=2) НЕ срабатывал НИКОГДА, +# даже для региона/города. Кэш-агностичен — смотрит на geo.full_address, каким бы +# провайдером/кэшем он ни был получен. +# +# Граница (? bool: + """True если геокод разрешился лишь до centroid'а НП/города/региона (без дома). + + Два сигнала (OR): + 1. provider confidence == 'locality' — явный centroid-маркер (на будущее: + текущие провайдеры его не эмитят, но enum/кэш-колонка допускают, и так + геокодер можно улучшить позже без правки гейта). + 2. в geo.full_address нет house-number токена (1-3 цифры) → геокодер не дошёл + до дома, вернул центр НП/города/региона. + Гарантирует ZERO ложных downgrade на реальных адресах: у любого реального дома + есть номер → токен матчится → не coarse. + """ + if geo.confidence == "locality": + return True + return _HOUSE_NUMBER_RE.search(geo.full_address or "") is None + + # ── Time-budget guard (#654) ──────────────────────────────────────────────── async def _with_budget(coro: Any, budget_s: float, *, label: str) -> Any: """Await `coro` under an asyncio.wait_for() time budget. @@ -1930,16 +1962,19 @@ async def estimate_quality( # 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, "населённого пункта" - ) + # ИСТОЧНИКИ сигнала грубости (OR): dadata.qc_geo>=2 (если DaData активна) ИЛИ + # _geocode_is_coarse(geo) — прод-фолбэк, работает БЕЗ DaData (#693 QA-fail: + # на проде DaData off → qc_geo всегда None → старый гейт не срабатывал). + dadata_coarse = dadata is not None and dadata.qc_geo is not None and dadata.qc_geo >= 2 + geo_coarse = _geocode_is_coarse(geo) + if (dadata_coarse or geo_coarse) and anchor_tier is None and median_price > 0: + # Уровень грубости берём из DaData (точнее), иначе generic «населённого пункта». + if dadata_coarse: + _coarse_label = {2: "населённого пункта", 3: "города", 4: "региона"}.get( + dadata.qc_geo, "населённого пункта" + ) + else: + _coarse_label = "населённого пункта" confidence = "low" explanation = (explanation or "") + ( f" Адрес определён лишь до уровня {_coarse_label} — точные координаты " diff --git a/tradein-mvp/backend/tests/test_same_building_anchor.py b/tradein-mvp/backend/tests/test_same_building_anchor.py index 7c0f6f60..890c706c 100644 --- a/tradein-mvp/backend/tests/test_same_building_anchor.py +++ b/tradein-mvp/backend/tests/test_same_building_anchor.py @@ -23,6 +23,7 @@ import anyio from app.services.estimator import ( _compute_same_building_anchor, + _geocode_is_coarse, _normalize_building_key, ) @@ -379,6 +380,7 @@ def _run_estimate( payload=None, radius_analogs: list[dict[str, Any]] | None = None, dadata_result: Any | None = None, + geo_result: Any | None = None, ): """estimate_quality со всеми I/O застабленными; _fetch_anchor_comps форсирован. @@ -396,7 +398,10 @@ def _run_estimate( async def _run(): 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.geocode", + new=AsyncMock(return_value=geo_result or _make_fake_geo()), + ), patch( "app.services.estimator.dadata_clean_address", new=AsyncMock(return_value=dadata_result), @@ -808,3 +813,84 @@ def test_estimate_no_dadata_no_false_downgrade() -> None: dadata_result=None, ) assert _APPROX_FLAG not in (est.confidence_explanation or "") + + +# ── #693 prod-fix: coarse geocode detected WITHOUT DaData (QA-fail #693) ────── + + +def _make_fake_geo_coarse( + full_address: str = "Россия, Свердловская область, Екатеринбург", +): + """Геокод-centroid без дома (город/регион) — прод-сценарий, когда DaData off. + + full_address не содержит house-number токена → _geocode_is_coarse → True. + """ + from app.services.geocoder import GeocodeResult + + return GeocodeResult( + lat=56.838, + lon=60.605, + full_address=full_address, + provider="yandex", + ) + + +def test_geocode_is_coarse_signal() -> None: + """Юнит: detector ловит centroid (нет дома) и пропускает реальные адреса.""" + from app.services.geocoder import GeocodeResult + + def _geo(addr: str, conf: str = "approximate"): + return GeocodeResult( + lat=56.8, lon=60.6, full_address=addr, provider="yandex", confidence=conf + ) # type: ignore[arg-type] + + # Coarse — нет house-number токена. + assert _geocode_is_coarse(_geo("Россия, Свердловская область, Екатеринбург")) is True + assert _geocode_is_coarse(_geo("Свердловская область")) is True + # Почтовый индекс (6 цифр) НЕ считается домом → всё ещё coarse. + assert _geocode_is_coarse(_geo("Екатеринбург, 620000")) is True + # provider confidence == 'locality' → coarse даже при наличии цифр. + assert _geocode_is_coarse(_geo("улица 8 Марта, 12", conf="locality")) is True + # Precise — есть номер дома → НЕ coarse (zero false downgrade). + assert _geocode_is_coarse(_geo("Екатеринбург, ул. Малышева, 30")) is False + assert _geocode_is_coarse(_geo("ЕКБ, ул. Хохрякова, 48")) is False + assert _geocode_is_coarse(_geo("Екатеринбург, ул.白, 16а")) is False + + +def test_estimate_coarse_geo_no_dadata_downgrades_to_low() -> None: + """#693 QA-fail: DaData off (dadata=None), геокод свалился к city-centroid + (нет дома в full_address), якоря нет, но радиусные аналоги есть (median>0) → + confidence='low' + флаг «ориентировочной» оценки. Это прод-кейс «фывапролд 999».""" + est = _run_estimate( + anchor_comps=[], + anchor_tier=None, + dadata_result=None, + geo_result=_make_fake_geo_coarse(), + ) + assert est.median_price_rub > 0 + assert est.confidence == "low" + assert _APPROX_FLAG in (est.confidence_explanation or "") + + +def test_estimate_coarse_geo_region_no_dadata_downgrades() -> None: + """#693: регион-centroid («Свердловская область») без DaData → low + флаг.""" + est = _run_estimate( + anchor_comps=[], + anchor_tier=None, + dadata_result=None, + geo_result=_make_fake_geo_coarse(full_address="Свердловская область"), + ) + assert est.confidence == "low" + assert _APPROX_FLAG in (est.confidence_explanation or "") + + +def test_estimate_coarse_geo_with_anchor_no_dadata_not_downgraded() -> None: + """#693 protect #691: coarse-геокод без DaData, НО якорь дома (tier=A) сработал → + НЕ даунгрейдим (оценка стоит на реальных комплах дома, центроид не важен).""" + est = _run_estimate( + anchor_comps=_SB_COMPS_PREMIUM, + anchor_tier="A", + dadata_result=None, + geo_result=_make_fake_geo_coarse(), + ) + assert _APPROX_FLAG not in (est.confidence_explanation or "")