feat(tradein): integrate Avito IMV as 5th evaluation source (on-demand cached) #452

Merged
lekss361 merged 1 commit from feat/tradein-estimator-imv-integration into main 2026-05-23 13:08:22 +00:00
2 changed files with 298 additions and 3 deletions

View file

@ -31,6 +31,13 @@ from sqlalchemy.orm import Session
from app.schemas.trade_in import AggregatedEstimate, AnalogLot, TradeInEstimateInput
from app.services.geocoder import GeocodeResult, geocode
from app.services.house_metadata import get_house_metadata
from app.services.scrapers.avito_imv import (
IMVAddressNotFoundError,
IMVEvaluation,
compute_imv_cache_key,
evaluate_via_imv,
save_imv_evaluation,
)
logger = logging.getLogger(__name__)
@ -45,6 +52,24 @@ DEALS_PERIOD_MONTHS = 12 # сделки за последний год
# Поправочные коэффициенты на состояние ремонта. Аналоги в выборке — микс
# состояний (≈ "стандартный/косметический"), коэффициент сдвигает медиану под
# конкретный ремонт целевой квартиры. Встреча Птицы: ремонт влияет на цену.
_IMV_HOUSE_TYPE_MAP: dict[str | None, str | None] = {
"panel": "panel",
"brick": "brick",
"monolith": "monolith",
"monolith_brick": "monolith_brick",
"monolithic": "monolith",
"block": "block",
"wood": "wood",
None: None,
}
_IMV_REPAIR_MAP: dict[str | None, str | None] = {
"needs_repair": "required",
"standard": "cosmetic",
"good": "euro",
"excellent": "designer",
None: None,
}
_REPAIR_COEF: dict[str, float] = {
"needs_repair": 0.92, # требует ремонта — ниже рынка
"standard": 0.98,
@ -66,6 +91,115 @@ def _repair_coefficient(repair_state: str | None) -> float:
return _REPAIR_COEF.get(repair_state, 1.0)
# ── Avito IMV cache lookup (Stage 3) ────────────────────────────────────────
IMV_CACHE_TTL_HOURS = 24
async def _get_or_fetch_imv_cached(
db: Session,
*,
address: str,
rooms: int,
area_m2: float,
floor: int,
floor_at_home: int,
house_type: str,
renovation_type: str,
has_balcony: bool,
has_loggia: bool,
estimate_id_for_link: Any = None,
) -> IMVEvaluation | None:
"""Cached IMV lookup. TTL 24h по cache_key (sha256 of address + params).
1. compute cache_key
2. SELECT из avito_imv_evaluations WHERE cache_key = :ck AND fetched_at > NOW() - 24h
3. Если hit возвращаем reconstructed IMVEvaluation
4. Cache miss call evaluate_via_imv, save_imv_evaluation, return
Graceful: на любой error возвращаем None (estimator продолжает без IMV).
"""
try:
cache_key = compute_imv_cache_key(
address, rooms, area_m2, floor, floor_at_home,
house_type, renovation_type, has_balcony, has_loggia,
)
existing = db.execute(
text(
"""
SELECT id, cache_key, address, rooms, area_m2, floor, floor_at_home,
house_type, renovation_type, has_balcony, has_loggia,
lat, lon, geo_hash, avito_address_id, avito_location_id,
avito_metro_id, avito_district_id,
recommended_price, lower_price, higher_price, market_count,
raw_response, fetched_at
FROM avito_imv_evaluations
WHERE cache_key = :ck
AND fetched_at > NOW() - (:ttl_hours || ' hours')::interval
ORDER BY fetched_at DESC
LIMIT 1
"""
),
{"ck": cache_key, "ttl_hours": IMV_CACHE_TTL_HOURS},
).mappings().first()
if existing is not None:
logger.info(
"imv: cache HIT key=%s recommended=%d",
cache_key[:8], existing["recommended_price"],
)
from app.services.scrapers.avito_imv import IMVGeo
return IMVEvaluation(
cache_key=existing["cache_key"],
address=existing["address"],
rooms=existing["rooms"],
area_m2=float(existing["area_m2"]),
floor=existing["floor"],
floor_at_home=existing["floor_at_home"],
house_type=existing["house_type"],
renovation_type=existing["renovation_type"],
has_balcony=existing["has_balcony"],
has_loggia=existing["has_loggia"],
geo=IMVGeo(
geo_hash=existing["geo_hash"] or "",
lat=existing["lat"],
lon=existing["lon"],
avito_address_id=existing["avito_address_id"],
avito_location_id=existing["avito_location_id"],
avito_metro_id=existing["avito_metro_id"],
avito_district_id=existing["avito_district_id"],
),
recommended_price=existing["recommended_price"],
lower_price=existing["lower_price"],
higher_price=existing["higher_price"],
market_count=existing["market_count"],
raw_response=existing.get("raw_response"),
)
# Cache miss — fresh fetch
logger.info("imv: cache MISS key=%s — fetching fresh", cache_key[:8])
result = await evaluate_via_imv(
address=address, rooms=rooms, area_m2=area_m2,
floor=floor, floor_at_home=floor_at_home,
house_type=house_type, renovation_type=renovation_type,
has_balcony=has_balcony, has_loggia=has_loggia,
)
save_imv_evaluation(db, result, estimate_id=estimate_id_for_link)
logger.info(
"imv: fresh recommended=%d range=(%d, %d) count=%d",
result.recommended_price, result.lower_price, result.higher_price,
result.market_count or 0,
)
return result
except IMVAddressNotFoundError as e:
logger.warning("imv: address not found in Avito geocoder: %s", e)
return None
except Exception as e:
logger.warning("imv: fetch failed — estimator продолжает без IMV: %s", e)
return None
# ── Public ───────────────────────────────────────────────────────────────────
async def estimate_quality(
payload: TradeInEstimateInput, db: Session
@ -173,6 +307,40 @@ async def estimate_quality(
)
explanation = (explanation or "") + repair_note
# ── Stage 3: Avito IMV evaluation as 5-th source (on-demand cached) ──
imv_eval: IMVEvaluation | None = None
imv_house_type = _IMV_HOUSE_TYPE_MAP.get(target_house_type)
imv_renovation = _IMV_REPAIR_MAP.get(payload.repair_state)
# IMV требует: address, rooms, area, floor, floor_at_home, house_type, renovation_type.
# Если payload не содержит required fields — skip IMV (graceful).
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
and imv_house_type is not None
and imv_renovation is not None
):
imv_eval = await _get_or_fetch_imv_cached(
db,
address=geo.full_address,
rooms=payload.rooms,
area_m2=payload.area_m2,
floor=payload.floor,
floor_at_home=payload.total_floors,
house_type=imv_house_type,
renovation_type=imv_renovation,
has_balcony=bool(payload.has_balcony),
has_loggia=False, # payload не разделяет балкон/лоджия → дефолт False
)
# Include IMV в sources_used если получили
sources_used_pre = sorted({lot.get("source") for lot in listings_clean if lot.get("source")})
if imv_eval is not None:
sources_used_pre = sorted(set(sources_used_pre) | {"avito_imv"})
# 5. Deals — фактические сделки за период
deals = _fetch_deals(
db, lat=geo.lat, lon=geo.lon, rooms=payload.rooms, area=payload.area_m2,
@ -186,8 +354,6 @@ async def estimate_quality(
analogs_lots = [_listing_to_analog(lot) for lot in listings_clean[:10]]
deals_lots = [_deal_to_analog(d) for d in deals[:10]]
sources_used_pre = sorted({lot.get("source") for lot in listings_clean if lot.get("source")})
freshness_pre = _compute_freshness_minutes(listings_clean)
db.execute(
text(
@ -255,8 +421,26 @@ async def estimate_quality(
)
db.commit()
# Link saved IMV evaluation к этому estimate_id (для analytics joining)
if imv_eval is not None:
try:
db.execute(
text(
"""
UPDATE avito_imv_evaluations
SET estimate_id = CAST(:estimate_id AS uuid)
WHERE cache_key = :cache_key
AND (estimate_id IS NULL OR estimate_id = CAST(:estimate_id AS uuid))
"""
),
{"estimate_id": str(estimate_id), "cache_key": imv_eval.cache_key},
)
db.commit()
except Exception as e:
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)",
"estimate: id=%s addr=%s rooms=%d area=%.1f → median=%d (n=%d, conf=%s)%s",
estimate_id,
geo.full_address[:60],
payload.rooms,
@ -264,9 +448,12 @@ async def estimate_quality(
median_price,
n_analogs,
confidence,
f" imv={imv_eval.recommended_price}" if imv_eval else "",
)
sources_used = sorted({lot.source for lot in analogs_lots if lot.source})
if imv_eval is not None:
sources_used = sorted(set(sources_used) | {"avito_imv"})
freshness_min = _compute_freshness_minutes(listings_clean)
return AggregatedEstimate(

View file

@ -0,0 +1,108 @@
"""Offline smoke for estimator IMV integration. Mocks IMV fetch + DB."""
import os
# Settings требует DATABASE_URL при инициализации (fail-fast, C-3).
# Для offline unit-тестов задаём dummy DSN до любого импорта из app.
os.environ.setdefault("DATABASE_URL", "postgresql://test:test@localhost/test_db")
from unittest.mock import AsyncMock, MagicMock, patch
import anyio
from app.services.estimator import (
_IMV_HOUSE_TYPE_MAP,
_IMV_REPAIR_MAP,
_get_or_fetch_imv_cached,
)
def test_imv_house_type_map() -> None:
assert _IMV_HOUSE_TYPE_MAP["panel"] == "panel"
assert _IMV_HOUSE_TYPE_MAP["monolithic"] == "monolith"
assert _IMV_HOUSE_TYPE_MAP[None] is None
assert _IMV_HOUSE_TYPE_MAP.get("unknown") is None
def test_imv_repair_map() -> None:
assert _IMV_REPAIR_MAP["needs_repair"] == "required"
assert _IMV_REPAIR_MAP["excellent"] == "designer"
assert _IMV_REPAIR_MAP[None] is None
def test_imv_cache_miss_calls_evaluate() -> None:
"""Cache miss → calls evaluate_via_imv + save_imv_evaluation."""
from app.services.scrapers.avito_imv import IMVEvaluation, IMVGeo
mock_db = MagicMock()
mock_db.execute.return_value.mappings.return_value.first.return_value = None # no cache hit
fake_result = IMVEvaluation(
cache_key="x" * 64,
address="ЕКБ test",
rooms=2,
area_m2=42.0,
floor=4,
floor_at_home=5,
house_type="panel",
renovation_type="cosmetic",
has_balcony=True,
has_loggia=False,
geo=IMVGeo(geo_hash="JWT"),
recommended_price=6_290_000,
lower_price=6_100_000,
higher_price=6_600_000,
market_count=800,
)
async def _run() -> None:
with (
patch(
"app.services.estimator.evaluate_via_imv",
new=AsyncMock(return_value=fake_result),
),
patch("app.services.estimator.save_imv_evaluation", return_value=1),
):
result = await _get_or_fetch_imv_cached(
mock_db,
address="ЕКБ test",
rooms=2,
area_m2=42.0,
floor=4,
floor_at_home=5,
house_type="panel",
renovation_type="cosmetic",
has_balcony=True,
has_loggia=False,
)
assert result is not None
assert result.recommended_price == 6_290_000
anyio.run(_run)
def test_imv_fetch_failure_returns_none_gracefully() -> None:
"""Network failure → returns None, не raises."""
mock_db = MagicMock()
mock_db.execute.return_value.mappings.return_value.first.return_value = None
async def _run() -> None:
with patch(
"app.services.estimator.evaluate_via_imv",
new=AsyncMock(side_effect=RuntimeError("test net error")),
):
result = await _get_or_fetch_imv_cached(
mock_db,
address="X",
rooms=2,
area_m2=42.0,
floor=4,
floor_at_home=5,
house_type="panel",
renovation_type="cosmetic",
has_balcony=True,
has_loggia=False,
)
assert result is None
anyio.run(_run)