From fcded1a6522040742ebc45cac4101c307cfdef9e Mon Sep 17 00:00:00 2001 From: lekss361 Date: Sat, 23 May 2026 17:22:51 +0300 Subject: [PATCH] =?UTF-8?q?feat(tradein):=20estimator=20=E2=80=94=20Cian?= =?UTF-8?q?=20Valuation=20as=207th=20evaluation=20source=20(Stage=209)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tradein-mvp/backend/app/services/estimator.py | 42 ++- .../tests/test_estimator_cian_integration.py | 244 ++++++++++++++++++ 2 files changed, 285 insertions(+), 1 deletion(-) create mode 100644 tradein-mvp/backend/tests/test_estimator_cian_integration.py diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index 7b2827a3..3071e6f9 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -39,6 +39,10 @@ from app.services.scrapers.avito_imv import ( evaluate_via_imv, save_imv_evaluation, ) +from app.services.scrapers.cian_valuation import ( + CianValuationResult, + estimate_via_cian_valuation, +) from app.services.scrapers.yandex_valuation import ( YandexValuationResult, YandexValuationScraper, @@ -546,6 +550,39 @@ async def estimate_quality( len(yandex_val.history_items), saved_hist, ) + # ── Stage 9: Cian Valuation as 7th source (on-demand, 24h cached, graceful if no cookies) ── + cian_val: CianValuationResult | None = None + if ( + geo is not None + and geo.full_address + and payload.rooms is not None + and payload.area_m2 + and payload.floor is not None + and payload.total_floors is not None + ): + try: + cian_val = await estimate_via_cian_valuation( + db, + address=geo.full_address, + total_area=payload.area_m2, + rooms_count=payload.rooms, + floor=payload.floor, + total_floors=payload.total_floors, + repair_type="cosmetic", + deal_type="sale", + use_cache=True, + ) + if cian_val is not None and cian_val.sale_price_rub: + sources_used_pre = sorted(set(sources_used_pre) | {"cian_valuation"}) + logger.info( + "cian_valuation: price=%s accuracy=%s house_id=%s", + cian_val.sale_price_rub, + cian_val.sale_accuracy, + cian_val.external_house_id, + ) + except Exception as exc: + logger.warning("cian_valuation: lookup failed (graceful): %s", exc) + # 5. Deals — фактические сделки за период deals = _fetch_deals( db, lat=geo.lat, lon=geo.lon, rooms=payload.rooms, area=payload.area_m2, @@ -645,7 +682,7 @@ async def estimate_quality( logger.warning("imv: failed to link estimate_id to evaluation: %s", e) logger.info( - "estimate: id=%s addr=%s rooms=%d area=%.1f → median=%d (n=%d, conf=%s)%s", + "estimate: id=%s addr=%s rooms=%d area=%.1f → median=%d (n=%d, conf=%s)%s%s", estimate_id, geo.full_address[:60], payload.rooms, @@ -654,6 +691,7 @@ async def estimate_quality( n_analogs, confidence, f" imv={imv_eval.recommended_price}" if imv_eval else "", + f" cian={cian_val.sale_price_rub}" if cian_val and cian_val.sale_price_rub else "", ) sources_used = sorted({lot.source for lot in analogs_lots if lot.source}) @@ -661,6 +699,8 @@ async def estimate_quality( sources_used = sorted(set(sources_used) | {"avito_imv"}) if yandex_val is not None: sources_used = sorted(set(sources_used) | {"yandex_valuation"}) + if cian_val is not None and cian_val.sale_price_rub: + sources_used = sorted(set(sources_used) | {"cian_valuation"}) freshness_min = _compute_freshness_minutes(listings_clean) return AggregatedEstimate( diff --git a/tradein-mvp/backend/tests/test_estimator_cian_integration.py b/tradein-mvp/backend/tests/test_estimator_cian_integration.py new file mode 100644 index 00000000..ed03adc6 --- /dev/null +++ b/tradein-mvp/backend/tests/test_estimator_cian_integration.py @@ -0,0 +1,244 @@ +"""Tests for Cian Valuation integration in estimator.py (Stage 9).""" +import os + +# Settings requires DATABASE_URL at init time. Set dummy DSN before any app import. +os.environ.setdefault("DATABASE_URL", "postgresql://test:test@localhost/test_db") + +from unittest.mock import AsyncMock, MagicMock, patch + +import anyio + +from app.services.scrapers.cian_valuation import CianValuationResult + + +def _fake_cian_result(**kwargs) -> CianValuationResult: + result = CianValuationResult() + result.sale_price_rub = kwargs.get("sale_price_rub", 7_500_000.0) + result.sale_accuracy = kwargs.get("sale_accuracy", 85.0) + result.sale_price_from = kwargs.get("sale_price_from", 7_000_000.0) + result.sale_price_to = kwargs.get("sale_price_to", 8_000_000.0) + result.chart = kwargs.get("chart", [{"month_date": "2026-05-01", "price": 145_000}]) + result.chart_change_pct = kwargs.get("chart_change_pct", 1.5) + result.chart_change_direction = kwargs.get("chart_change_direction", "increase") + result.external_house_id = kwargs.get("external_house_id", 12345) + result.is_authenticated = kwargs.get("is_authenticated", True) + return result + + +# ── Cache-layer unit tests ──────────────────────────────────────────────────── + +def test_cian_valuation_cache_key_deterministic() -> None: + """compute_cache_key produces same hash for identical inputs.""" + from app.services.scrapers.cian_valuation import compute_cache_key + k1 = compute_cache_key("ЕКБ, ул. Учителей, 18", 38.8, 1, 4, "cosmetic", "sale") + k2 = compute_cache_key("ЕКБ, ул. Учителей, 18", 38.8, 1, 4, "cosmetic", "sale") + assert k1 == k2 + assert len(k1) == 64 + + +def test_cian_valuation_cache_key_differs_for_different_inputs() -> None: + from app.services.scrapers.cian_valuation import compute_cache_key + a = compute_cache_key("addr A", 38.8, 1, 4, "cosmetic", "sale") + b = compute_cache_key("addr B", 38.8, 1, 4, "cosmetic", "sale") + c = compute_cache_key("addr A", 50.0, 1, 4, "cosmetic", "sale") + d = compute_cache_key("addr A", 38.8, 2, 4, "cosmetic", "sale") + assert len({a, b, c, d}) == 4 + + +# ── estimate_via_cian_valuation graceful-degradation tests ─────────────────── + +def test_estimate_via_cian_returns_none_when_no_cookies() -> None: + """estimate_via_cian_valuation returns None when no cookies in DB (graceful).""" + db = MagicMock() + # cache miss + db.execute.return_value.mappings.return_value.first.return_value = None + + async def _run() -> None: + with patch( + "app.services.scrapers.cian_valuation.load_session", + return_value=None, # no cookies stored + ): + from app.services.scrapers.cian_valuation import estimate_via_cian_valuation + result = await estimate_via_cian_valuation( + db, + address="ЕКБ, ул. Учителей, 18", + total_area=38.8, + rooms_count=1, + floor=4, + total_floors=16, + use_cache=True, + ) + assert result is None + + anyio.run(_run) + + +# ── Estimator integration tests ─────────────────────────────────────────────── + +def _make_fake_geo(): + """Return GeocodeResult with provider field (not source).""" + from app.services.geocoder import GeocodeResult + return GeocodeResult( + lat=56.838, + lon=60.595, + full_address="Свердловская обл., Екатеринбург, ул. Учителей, 18", + provider="nominatim", + ) + + +def _make_payload(): + from app.schemas.trade_in import TradeInEstimateInput + return TradeInEstimateInput( + address="ЕКБ, ул. Учителей, 18", + area_m2=38.8, + rooms=1, + floor=4, + total_floors=16, + ) + + +def _common_patches(cian_mock): + """Return list of context managers for all sources except cian_valuation.""" + return [ + patch("app.services.estimator.geocode", new=AsyncMock(return_value=_make_fake_geo())), + patch("app.services.estimator.get_house_metadata", new=AsyncMock(return_value=None)), + patch("app.services.estimator._fetch_analogs", return_value=([], False)), + patch("app.services.estimator._fetch_deals", return_value=[]), + patch( + "app.services.estimator._get_or_fetch_imv_cached", + new=AsyncMock(return_value=None), + ), + patch( + "app.services.estimator._get_or_fetch_yandex_valuation_cached", + new=AsyncMock(return_value=None), + ), + patch( + "app.services.estimator.estimate_via_cian_valuation", + new=cian_mock, + ), + ] + + +def test_estimator_includes_cian_valuation_when_available() -> None: + """When cian_valuation returns result with sale_price_rub, sources_used includes it.""" + from app.services.estimator import estimate_quality + + db = MagicMock() + payload = _make_payload() + fake_cian = _fake_cian_result() + cian_mock = AsyncMock(return_value=fake_cian) + + async def _run() -> None: + with ( + patch("app.services.estimator.geocode", + new=AsyncMock(return_value=_make_fake_geo())), + patch("app.services.estimator.get_house_metadata", + new=AsyncMock(return_value=None)), + patch("app.services.estimator._fetch_analogs", return_value=([], False)), + patch("app.services.estimator._fetch_deals", return_value=[]), + patch("app.services.estimator._get_or_fetch_imv_cached", + new=AsyncMock(return_value=None)), + patch("app.services.estimator._get_or_fetch_yandex_valuation_cached", + new=AsyncMock(return_value=None)), + patch("app.services.estimator.estimate_via_cian_valuation", + new=cian_mock), + ): + result = await estimate_quality(payload, db) + + assert "cian_valuation" in result.sources_used + + anyio.run(_run) + + +def test_estimator_graceful_when_cian_returns_none() -> None: + """When estimate_via_cian_valuation returns None (no cookies), estimator continues.""" + from app.services.estimator import estimate_quality + + db = MagicMock() + payload = _make_payload() + cian_mock = AsyncMock(return_value=None) + + async def _run() -> None: + with ( + patch("app.services.estimator.geocode", + new=AsyncMock(return_value=_make_fake_geo())), + patch("app.services.estimator.get_house_metadata", + new=AsyncMock(return_value=None)), + patch("app.services.estimator._fetch_analogs", return_value=([], False)), + patch("app.services.estimator._fetch_deals", return_value=[]), + patch("app.services.estimator._get_or_fetch_imv_cached", + new=AsyncMock(return_value=None)), + patch("app.services.estimator._get_or_fetch_yandex_valuation_cached", + new=AsyncMock(return_value=None)), + patch("app.services.estimator.estimate_via_cian_valuation", + new=cian_mock), + ): + result = await estimate_quality(payload, db) + + assert "cian_valuation" not in result.sources_used + assert result.estimate_id is not None # estimator still returned a result + + anyio.run(_run) + + +def test_estimator_graceful_when_cian_raises() -> None: + """When estimate_via_cian_valuation raises, estimator logs warning and continues.""" + from app.services.estimator import estimate_quality + + db = MagicMock() + payload = _make_payload() + cian_mock = AsyncMock(side_effect=RuntimeError("network timeout")) + + async def _run() -> None: + with ( + patch("app.services.estimator.geocode", + new=AsyncMock(return_value=_make_fake_geo())), + patch("app.services.estimator.get_house_metadata", + new=AsyncMock(return_value=None)), + patch("app.services.estimator._fetch_analogs", return_value=([], False)), + patch("app.services.estimator._fetch_deals", return_value=[]), + patch("app.services.estimator._get_or_fetch_imv_cached", + new=AsyncMock(return_value=None)), + patch("app.services.estimator._get_or_fetch_yandex_valuation_cached", + new=AsyncMock(return_value=None)), + patch("app.services.estimator.estimate_via_cian_valuation", + new=cian_mock), + ): + result = await estimate_quality(payload, db) + + assert "cian_valuation" not in result.sources_used + assert result.estimate_id is not None + + anyio.run(_run) + + +def test_estimator_cian_result_no_sale_price_not_added() -> None: + """When cian_valuation returns result with sale_price_rub=None, not added to sources.""" + from app.services.estimator import estimate_quality + + db = MagicMock() + payload = _make_payload() + # Result returned but no sale price (e.g. partial parse) + fake_cian_no_price = _fake_cian_result(sale_price_rub=None) + cian_mock = AsyncMock(return_value=fake_cian_no_price) + + async def _run() -> None: + with ( + patch("app.services.estimator.geocode", + new=AsyncMock(return_value=_make_fake_geo())), + patch("app.services.estimator.get_house_metadata", + new=AsyncMock(return_value=None)), + patch("app.services.estimator._fetch_analogs", return_value=([], False)), + patch("app.services.estimator._fetch_deals", return_value=[]), + patch("app.services.estimator._get_or_fetch_imv_cached", + new=AsyncMock(return_value=None)), + patch("app.services.estimator._get_or_fetch_yandex_valuation_cached", + new=AsyncMock(return_value=None)), + patch("app.services.estimator.estimate_via_cian_valuation", + new=cian_mock), + ): + result = await estimate_quality(payload, db) + + assert "cian_valuation" not in result.sources_used + + anyio.run(_run) -- 2.45.3