feat(tradein): estimator — Cian Valuation as 7th evaluation source (Stage 9)
This commit is contained in:
parent
b26e0ff7c3
commit
fcded1a652
2 changed files with 285 additions and 1 deletions
|
|
@ -39,6 +39,10 @@ from app.services.scrapers.avito_imv import (
|
||||||
evaluate_via_imv,
|
evaluate_via_imv,
|
||||||
save_imv_evaluation,
|
save_imv_evaluation,
|
||||||
)
|
)
|
||||||
|
from app.services.scrapers.cian_valuation import (
|
||||||
|
CianValuationResult,
|
||||||
|
estimate_via_cian_valuation,
|
||||||
|
)
|
||||||
from app.services.scrapers.yandex_valuation import (
|
from app.services.scrapers.yandex_valuation import (
|
||||||
YandexValuationResult,
|
YandexValuationResult,
|
||||||
YandexValuationScraper,
|
YandexValuationScraper,
|
||||||
|
|
@ -546,6 +550,39 @@ async def estimate_quality(
|
||||||
len(yandex_val.history_items), saved_hist,
|
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 — фактические сделки за период
|
# 5. Deals — фактические сделки за период
|
||||||
deals = _fetch_deals(
|
deals = _fetch_deals(
|
||||||
db, lat=geo.lat, lon=geo.lon, rooms=payload.rooms, area=payload.area_m2,
|
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.warning("imv: failed to link estimate_id to evaluation: %s", e)
|
||||||
|
|
||||||
logger.info(
|
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,
|
estimate_id,
|
||||||
geo.full_address[:60],
|
geo.full_address[:60],
|
||||||
payload.rooms,
|
payload.rooms,
|
||||||
|
|
@ -654,6 +691,7 @@ async def estimate_quality(
|
||||||
n_analogs,
|
n_analogs,
|
||||||
confidence,
|
confidence,
|
||||||
f" imv={imv_eval.recommended_price}" if imv_eval else "",
|
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})
|
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"})
|
sources_used = sorted(set(sources_used) | {"avito_imv"})
|
||||||
if yandex_val is not None:
|
if yandex_val is not None:
|
||||||
sources_used = sorted(set(sources_used) | {"yandex_valuation"})
|
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)
|
freshness_min = _compute_freshness_minutes(listings_clean)
|
||||||
|
|
||||||
return AggregatedEstimate(
|
return AggregatedEstimate(
|
||||||
|
|
|
||||||
244
tradein-mvp/backend/tests/test_estimator_cian_integration.py
Normal file
244
tradein-mvp/backend/tests/test_estimator_cian_integration.py
Normal file
|
|
@ -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)
|
||||||
Loading…
Add table
Reference in a new issue