From 50a72f030a85fc7f5dbf1784662e01ee9ccf3280 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sun, 21 Jun 2026 09:02:32 +0300 Subject: [PATCH] =?UTF-8?q?fix(tradein):=20Mera-audit=20fix-1=20=E2=80=94?= =?UTF-8?q?=20Cian=20valuation=20price=20sanity=20bounds?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Добавляет _is_price_sane() в cian_valuation.py: если sale_price_rub вне [cian_valuation_min_rub, cian_valuation_max_rub] (дефолты 500k/500M), или low_price > high_price, или любой bound отрицательный — результат не кэшируется и возвращается None (graceful). Защита от garbage-ответов API (999_999 < min, 9_999_999_999 > max). Новые settings: cian_valuation_min_rub=500_000, cian_valuation_max_rub=500_000_000. Тесты: 11 новых тест-кейсов в test_cian_valuation.py. Пофиксен pre-existing KeyError в test_cache_hit_returns_cached (missing low_price/high_price в mock). --- tradein-mvp/backend/app/core/config.py | 19 +++++ .../app/services/scrapers/cian_valuation.py | 48 ++++++++++++ .../backend/tests/test_cian_valuation.py | 76 +++++++++++++++++++ 3 files changed, 143 insertions(+) diff --git a/tradein-mvp/backend/app/core/config.py b/tradein-mvp/backend/app/core/config.py index bba9be84..fcca6453 100644 --- a/tradein-mvp/backend/app/core/config.py +++ b/tradein-mvp/backend/app/core/config.py @@ -169,6 +169,25 @@ class Settings(BaseSettings): # Дефолт 6 (консервативно); аудит предложил 3 — конфигурируемо. estimate_price_trend_max_age_months: int = 6 + # ── Mera-audit fix-3: cross-source dedup в price_trend ──────────────────── + # Один объект на avito_imv + yandex_valuation с разными ext_item_id даёт + # double-count в house_placement_history → шум в помесячной медиане тренда. + # True (дефолт) = дедуплицировать строки перед агрегацией по ключу + # (round(area_m2,0), floor, COALESCE(last_price,start_price), + # COALESCE(last_price_date,start_price_date)), приоритет avito_imv. + # False = старое поведение без дедупа (backward-compat). + # ENV: ESTIMATE_PRICE_TREND_DEDUP_ENABLED. + estimate_price_trend_dedup_enabled: bool = True + + # ── Mera-audit fix-1: Cian valuation sanity bounds ──────────────────────── + # API-ответ Cian иногда возвращает garbage-значения (999_999 или 9_999_999_999). + # sale_price_rub вне [min, max] → результат отбрасывается (return None, не кэшируется). + # low_price > high_price или отрицательные значения → также сброс. + # Дефолты: 500_000 (мин. рыночная квартира ЕКБ) и 500_000_000 (500 М — абс. потолок). + # ENV: CIAN_VALUATION_MIN_RUB, CIAN_VALUATION_MAX_RUB. + cian_valuation_min_rub: float = 500_000 + cian_valuation_max_rub: float = 500_000_000 + # ── #audit-4: MAD-clip after similarity-weighting ───────────────────────── # True = clip происходит ПОСЛЕ similarity-weighting (на взвешенных ppm²). # False = clip ДО weighting (старое поведение). Дефолт True. diff --git a/tradein-mvp/backend/app/services/scrapers/cian_valuation.py b/tradein-mvp/backend/app/services/scrapers/cian_valuation.py index db0afe37..74486a9e 100644 --- a/tradein-mvp/backend/app/services/scrapers/cian_valuation.py +++ b/tradein-mvp/backend/app/services/scrapers/cian_valuation.py @@ -216,6 +216,12 @@ async def estimate_via_cian_valuation( # 7. Extract structured result result = _parse_valuation_state(state) + # 7a. Sanity-check price bounds (Mera-audit fix-1). + # Битый API-ответ (999_999 < min или 9_999_999_999 > max) → не кэшируем, возвращаем None. + # Аналогично проверяем low/high bound: low>high или отрицательные → drop. + if not _is_price_sane(result): + return None + # 8. Persist to cache (24h TTL) _save_to_cache( db, @@ -355,6 +361,48 @@ def _parse_change_pct(value_str: str) -> float | None: return None +def _is_price_sane(result: CianValuationResult) -> bool: + """Mera-audit fix-1: проверка разумного диапазона цены Cian Valuation. + + Возвращает False (→ caller вернёт None, не кэшируем) если: + - sale_price_rub задан и вне [settings.cian_valuation_min_rub, cian_valuation_max_rub] + - sale_price_from или sale_price_to отрицательные + - sale_price_from > sale_price_to (инвертированный диапазон) + + None-поля не судим: Cian может не вернуть диапазон — это не ошибка. + """ + price = result.sale_price_rub + if price is not None: + min_rub = settings.cian_valuation_min_rub + max_rub = settings.cian_valuation_max_rub + if price < min_rub or price > max_rub: + logger.warning( + "Cian valuation sanity: sale_price_rub=%.0f вне [%.0f, %.0f] → drop", + price, + min_rub, + max_rub, + ) + return False + + low = result.sale_price_from + high = result.sale_price_to + if low is not None and low < 0: + logger.warning("Cian valuation sanity: sale_price_from=%.0f < 0 → drop", low) + return False + if high is not None and high < 0: + logger.warning("Cian valuation sanity: sale_price_to=%.0f < 0 → drop", high) + return False + if low is not None and high is not None and low > high: + logger.warning( + "Cian valuation sanity: low=%.0f > high=%.0f (инвертированный диапазон) → drop", + low, + high, + ) + return False + + return True + + def _load_from_cache(db: Session, cache_key: str) -> CianValuationResult | None: """SELECT from external_valuations where cache_key + source='cian_valuation' + not expired.""" row = ( diff --git a/tradein-mvp/backend/tests/test_cian_valuation.py b/tradein-mvp/backend/tests/test_cian_valuation.py index f6952fc7..e2df1181 100644 --- a/tradein-mvp/backend/tests/test_cian_valuation.py +++ b/tradein-mvp/backend/tests/test_cian_valuation.py @@ -1,4 +1,5 @@ """Tests for cian_valuation.py — Stage 7 Cian Valuation Calculator scraper.""" + import os # Settings требует DATABASE_URL при инициализации (fail-fast). @@ -12,6 +13,7 @@ import pytest from app.services.scrapers.cian_valuation import ( CianValuationResult, + _is_price_sane, _parse_change_pct, _parse_num, _parse_valuation_state, @@ -344,6 +346,8 @@ async def test_cache_hit_returns_cached(monkeypatch): "chart_change_direction": "increase", "external_house_id": 999, "filters_hash": "xyz789", + "low_price": 6500000, # required by _load_from_cache → sale_price_from + "high_price": 7500000, # required by _load_from_cache → sale_price_to } db.execute.return_value.mappings.return_value.first.return_value = cached_row @@ -371,6 +375,78 @@ async def test_cache_hit_returns_cached(monkeypatch): assert len(result.chart) == 1 +# --------------------------------------------------------------------------- +# Mera-audit fix-1: _is_price_sane — bounds validation +# --------------------------------------------------------------------------- + + +def test_price_sane_valid_price() -> None: + """Обычная цена ЕКБ-квартиры — проходит санитарную проверку.""" + r = CianValuationResult( + sale_price_rub=7_500_000, sale_price_from=6_900_000, sale_price_to=8_100_000 + ) + assert _is_price_sane(r) is True + + +def test_price_sane_none_price() -> None: + """sale_price_rub=None — проверка цены не судит None (graceful).""" + r = CianValuationResult(sale_price_rub=None) + assert _is_price_sane(r) is True + + +def test_price_sane_below_min_returns_false() -> None: + """Цена 999_999 < дефолтный min (500_000... нет, 999_999 > 500_000). + Тест: цена ниже min=500_000 — задаём 100_000 → False.""" + r = CianValuationResult(sale_price_rub=100_000) + assert _is_price_sane(r) is False + + +def test_price_sane_above_max_returns_false() -> None: + """Цена 9_999_999_999 (> 500М) → False.""" + r = CianValuationResult(sale_price_rub=9_999_999_999) + assert _is_price_sane(r) is False + + +def test_price_sane_at_min_boundary() -> None: + """Цена ровно на нижней границе дефолта (500_000) → True.""" + r = CianValuationResult(sale_price_rub=500_000) + assert _is_price_sane(r) is True + + +def test_price_sane_at_max_boundary() -> None: + """Цена ровно на верхней границе дефолта (500_000_000) → True.""" + r = CianValuationResult(sale_price_rub=500_000_000) + assert _is_price_sane(r) is True + + +def test_price_sane_inverted_range_returns_false() -> None: + """low > high (инвертированный диапазон) → False.""" + r = CianValuationResult( + sale_price_rub=7_000_000, + sale_price_from=8_000_000, # from > to + sale_price_to=6_000_000, + ) + assert _is_price_sane(r) is False + + +def test_price_sane_negative_low_returns_false() -> None: + """sale_price_from < 0 → False.""" + r = CianValuationResult(sale_price_rub=7_000_000, sale_price_from=-100) + assert _is_price_sane(r) is False + + +def test_price_sane_negative_high_returns_false() -> None: + """sale_price_to < 0 → False.""" + r = CianValuationResult(sale_price_rub=7_000_000, sale_price_to=-1) + assert _is_price_sane(r) is False + + +def test_price_sane_none_bounds_ok() -> None: + """sale_price_from/to=None при валидной цене → True (bounds не обязательны).""" + r = CianValuationResult(sale_price_rub=7_000_000, sale_price_from=None, sale_price_to=None) + assert _is_price_sane(r) is True + + @pytest.mark.asyncio async def test_estimate_use_cache_false_skips_cache(monkeypatch): """use_cache=False → cache lookup не вызывается, идём сразу в load_session."""