diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index acb7f440..89b9e84b 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -488,6 +488,32 @@ def _repair_coefficient(repair_state: str | None) -> float: return _REPAIR_COEF.get(repair_state, 1.0) +# Обратный `_IMV_REPAIR_MAP`: вокабуляр Avito → наш repair_state. +_IMV_REPAIR_MAP_REV: dict[str, str] = {v: k for k, v in _IMV_REPAIR_MAP.items() if k and v} + + +def _anchor_repair_factor(anchor_renovation: str | None, target_repair: str | None) -> float: + """#2677: множитель, приводящий домовой IMV-якорь к базису ремонта target'а. + + Домовая строка `house_imv_evaluations` запрошена у Avito с ОДНИМ ремонтом + (`renovation_type`, до #2675 — литерал 'cosmetic' у всех 2685 строк), а медиана + к моменту blend'а уже домножена на `_repair_coefficient(target_repair)`. Сравнивать + и смешивать их напрямую — значит класть два разных базиса на одну шкалу: у клиента + с «требует ремонта» медиана −6%, якорь нет, порог `median×1.15` фактически падает до + ×1.081, а сработавший blend возвращает половину этих −6% обратно вверх. + + Это НЕ заявка на правильность самих коэффициентов (`_REPAIR_COEF` — рыночная + эвристика, см. предупреждение выше): множитель берётся ровно тот, что код УЖЕ + применил к медиане, — чинится расхождение базисов, а не калибровка. + + Неизвестный/незнакомый ремонт с любой стороны → 1.0 (no-op, прежнее поведение). + """ + base = _repair_coefficient(_IMV_REPAIR_MAP_REV.get(anchor_renovation or "")) + if base <= 0: + return 1.0 + return _repair_coefficient(target_repair) / base + + # ── Asking→sold correction ratio lookup (#648 Stage 3) ────────────────────── # Таблица asking_to_sold_ratios (migration 080) хранит per-rooms коэффициент # ratio = median(SOLD ppm²) / median(ASKING ppm²) (~0.72–0.93). Estimator @@ -1165,7 +1191,11 @@ def _fetch_house_imv_anchor( house_id / пустой таблице — estimator продолжает на гео-tier'ах (no regress). Returns dict {recommended_price, lower_price, higher_price, market_count, - rooms, area_m2} или None. + rooms, area_m2, renovation_type} или None. + + #2677: renovation_type — ремонт, С КОТОРЫМ строка была запрошена у Avito. + Без него якорь нельзя привести к базису ремонта оцениваемой квартиры + (см. `_anchor_repair_factor`), и правка молча выродилась бы в no-op. """ if target_house_id is None: return None @@ -1175,7 +1205,7 @@ def _fetch_house_imv_anchor( text( """ SELECT recommended_price, lower_price, higher_price, - market_count, rooms, area_m2 + market_count, rooms, area_m2, renovation_type FROM house_imv_evaluations WHERE house_id = CAST(:hid AS bigint) AND recommended_price > 0 @@ -3114,6 +3144,7 @@ def _price_from_inputs( anchor_total: int | None = None anchor_higher: int | None = None anchor_label: str | None = None + anchor_repair_note: str = "" if imv_anchor is not None and imv_anchor.get("recommended_price"): anchor_total = int(imv_anchor["recommended_price"]) anchor_higher = ( @@ -3132,6 +3163,22 @@ def _price_from_inputs( _imv_mc is not None and _imv_mc < settings.avito_imv_thin_market_threshold ), ) + # #2677: карточка выше показывает СЫРУЮ оценку Avito (это чужое число, мы его + # не правим), а в blend уходит якорь, приведённый к базису ремонта target'а — + # тому же, в котором median_price уже посчитан выше (repair_coef). + _rep_k = _anchor_repair_factor(imv_anchor.get("renovation_type"), repair_state) + if _rep_k != 1.0: + anchor_total = round(anchor_total * _rep_k) + anchor_higher = round(anchor_higher * _rep_k) if anchor_higher else None + anchor_repair_note = ", приведённой к состоянию ремонта квартиры" + logger.info( + "imv_anchor repair-basis #2677: renovation=%s target_repair=%s k=%.3f" + " → anchor %d", + imv_anchor.get("renovation_type"), + repair_state, + _rep_k, + anchor_total, + ) elif imv_eval is not None and imv_eval.recommended_price: anchor_total = int(imv_eval.recommended_price) anchor_higher = int(imv_eval.higher_price) if imv_eval.higher_price else None @@ -3181,8 +3228,8 @@ def _price_from_inputs( median_price = new_median median_ppm2 = new_ppm2 explanation = (explanation or "") + ( - f" Оценка скорректирована по {anchor_label} " - f"({anchor_used / 1_000_000:.1f} млн ₽)." + f" Оценка скорректирована по {anchor_label}" + f"{anchor_repair_note} ({anchor_used / 1_000_000:.1f} млн ₽)." ) sources_used_pre = sorted(set(sources_used_pre) | {"avito_imv"}) # Диапазон расширяем даже если медиану не двигали. diff --git a/tradein-mvp/backend/tests/test_2677_house_anchor_repair_basis.py b/tradein-mvp/backend/tests/test_2677_house_anchor_repair_basis.py new file mode 100644 index 00000000..6a4ce8be --- /dev/null +++ b/tradein-mvp/backend/tests/test_2677_house_anchor_repair_basis.py @@ -0,0 +1,226 @@ +"""#2677: домовой IMV-якорь приводится к базису ремонта оцениваемой квартиры. + +Дефект: `median_price` к моменту blend'а уже домножен на `_repair_coefficient` +(«требует ремонта» −6%, «евро» +10%), а домовой якорь из `house_imv_evaluations` +запрошен у Avito с ОДНИМ ремонтом (`renovation_type`). Порог `anchor > median×1.15` +и сам blend клали два разных базиса на одну шкалу — и, поскольку blend +однонаправленный (только вверх), у клиента с «требует ремонта» это возвращало +половину его −6% обратно наверх. + +Замер на проде (1061 персистированная оценка, 2026-08-10): blend не сработал ни +разу (0 маркеров в confidence_explanation), но из 240 оценок с домовым якорем порог +пересекали 19 — 9 из них «требует ремонта», и на них старый код давал медиану +на 3.3–9.8 % выше базис-согласованной (суммарно +4.13 млн ₽). +""" + +import os +from typing import Any + +# Settings требует DATABASE_URL при инициализации (fail-fast, C-3). +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db") + +from unittest.mock import AsyncMock, MagicMock, patch + +import anyio + +from app.services.estimator import _fetch_house_imv_anchor + +# `_anchor_repair_factor` импортируется ВНУТРИ тестов намеренно: на коде без правки +# модуль обязан импортироваться, чтобы сквозные тесты ниже дошли до выполнения и +# упали на ЦИФРАХ (красный по существу), а не на collection error. + +# Аналоги дают ровно 150 000 ₽/м² медианой → при area 40 м² pre-repair медиана 6 000 000 ₽. +_PPM2 = 150_000.0 +_AREA = 40.0 +_BASE_MEDIAN = int(_PPM2 * _AREA) + + +def _make_listing(price_per_m2: float) -> dict[str, Any]: + return { + "id": int(price_per_m2), + "source": "avito", + "address": "ЕКБ, ул. Учителей, 18", + "price_rub": int(price_per_m2 * _AREA), + "price_per_m2": price_per_m2, + "area_m2": _AREA, + "rooms": 1, + "floor": 4, + "total_floors": 16, + "lat": 56.838, + "lon": 60.595, + "listing_date": None, + "days_on_market": None, + "source_url": f"https://example.test/{int(price_per_m2)}", + "photo_urls": [], + "distance_m": 10.0, + } + + +_ANALOGS = [ + _make_listing(135_000.0), + _make_listing(140_000.0), + _make_listing(145_000.0), + _make_listing(_PPM2), + _make_listing(155_000.0), + _make_listing(160_000.0), + _make_listing(165_000.0), +] + + +def _run_estimate(anchor: dict[str, Any], repair_state: str | None) -> Any: + """estimate_quality со всеми I/O застабленными; домовой якорь форсирован.""" + from app.schemas.trade_in import TradeInEstimateInput + from app.services.estimator import estimate_quality + from app.services.geocoder import GeocodeResult + + payload = TradeInEstimateInput( + address="ЕКБ, ул. Учителей, 18", + area_m2=_AREA, + rooms=1, + floor=4, + total_floors=16, + repair_state=repair_state, + ) + geo = GeocodeResult( + lat=56.838, + lon=60.595, + full_address="Свердловская обл., Екатеринбург, ул. Учителей, 18", + provider="nominatim", + ) + + async def _run() -> Any: + with ( + patch("app.core.config.settings.estimate_hedonic_correction_enabled", new=False), + patch("app.services.estimator.geocode", new=AsyncMock(return_value=geo)), + patch("app.services.estimator.dadata_clean_address", new=AsyncMock(return_value=None)), + patch("app.services.estimator.match_house_readonly", return_value=None), + patch("app.services.estimator.get_house_metadata", new=AsyncMock(return_value=None)), + patch( + "app.services.estimator._fetch_analogs", + return_value=(list(_ANALOGS), False, "S"), + ), + patch("app.services.estimator._fetch_deals", return_value=[]), + patch("app.services.estimator._fetch_dkp_corridor", return_value=None), + 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=AsyncMock(return_value=None), + ), + patch("app.services.estimator._get_asking_sold_ratio", return_value=(0.8, "per_rooms")), + patch("app.services.estimator._fetch_house_imv_anchor", return_value=anchor), + ): + return await estimate_quality(payload, MagicMock()) + + return anyio.run(_run) + + +def _anchor(recommended: int, renovation: str | None) -> dict[str, Any]: + return { + "recommended_price": recommended, + "lower_price": int(recommended * 0.97), + "higher_price": int(recommended * 1.05), + "market_count": 500, + "rooms": 1, + "area_m2": _AREA, + "renovation_type": renovation, + } + + +# ── чистая функция ─────────────────────────────────────────────────────────── + + +def test_repair_factor_is_ratio_of_the_same_coefficients() -> None: + """Множитель = coef(ремонт клиента) / coef(ремонт строки якоря), а не что-то новое.""" + from app.services.estimator import _anchor_repair_factor + + # строка 'cosmetic' (=standard, 1.00) → клиент «требует ремонта» (0.94) + assert _anchor_repair_factor("cosmetic", "needs_repair") == 0.94 + # строка 'euro' (=good, 1.05) → клиент со стандартным ремонтом (1.00) + assert _anchor_repair_factor("euro", "standard") == 1.0 / 1.05 + # строка 'required' (=needs_repair, 0.94) → клиент с евро (1.10) + assert _anchor_repair_factor("required", "excellent") == 1.10 / 0.94 + + +def test_repair_factor_unknown_anchor_renovation_falls_back_to_standard_basis() -> None: + """Незнакомый/пустой `renovation_type` считается стандартным базисом, а НЕ «не трогать». + + Так строка и рождается: `house_imv_backfill._map_renovation_type` при неизвестном + ремонте дома шлёт Avito 'cosmetic' (=standard) как медиану популяции. Фолбэк в + 1.0 при дрейфе вокабуляра тихо вернул бы дефект — поэтому базис, а не no-op. + """ + from app.services.estimator import _anchor_repair_factor + + assert _anchor_repair_factor("что-то-новое", "needs_repair") == 0.94 + assert _anchor_repair_factor(None, "needs_repair") == 0.94 + # Неизвестен ремонт КЛИЕНТА — правки нет: медиана тоже осталась без коэффициента. + assert _anchor_repair_factor(None, None) == 1.0 + assert _anchor_repair_factor("cosmetic", None) == 1.0 + assert _anchor_repair_factor(None, "standard") == 1.0 + + +# ── SQL-граница: без колонки правка молча выродится в no-op ────────────────── + + +def test_anchor_query_selects_renovation_type() -> None: + """`renovation_type` обязан быть в SELECT: без него `.get()` вернёт None → k=1.0. + + Проверка строковая намеренно — это единственная точка, где видно, доедет ли + колонка из БД до `_anchor_repair_factor`; поштучный unit-тест по ту сторону + границы получает dict уже от вызывающего и такую регрессию не увидит. + """ + db = MagicMock() + db.execute.return_value.mappings.return_value.first.return_value = None + _fetch_house_imv_anchor(db, target_house_id=11308, rooms=1, area=_AREA) + sql = str(db.execute.call_args[0][0]) + assert "renovation_type" in sql.split("FROM house_imv_evaluations")[0] + + +# ── сквозь estimate_quality: цена, которую видит пользователь ──────────────── + + +def test_needs_repair_client_not_lifted_by_cosmetic_anchor() -> None: + """«Требует ремонта»: якорь между старым и базис-согласованным порогом → blend НЕ идёт. + + median = 6 000 000 × 0.94 = 5 640 000. Старый порог: 5 640 000 × 1.15 = 6 486 000 + — якорь 6 700 000 его перекрывает и старый код поднимает медиану до 6 170 000. + Базис-согласованно: якорь 'cosmetic' в базисе «требует ремонта» = 6 298 000 + < 6 486 000 → медиана остаётся 5 640 000 (−8.6 % к старому поведению). + """ + est = _run_estimate(_anchor(6_700_000, "cosmetic"), "needs_repair") + assert est.median_price_rub == int(_BASE_MEDIAN * 0.94) + assert "скорректирована по оценке Avito IMV" not in (est.confidence_explanation or "") + + +def test_euro_anchor_not_applied_raw_to_unknown_repair_client() -> None: + """Сценарий из #2677: дом переснят как «евро», ремонт клиента неизвестен. + + median = 6 000 000 (coef 1.0). Старый порог 6 900 000 — якорь 7 000 000 его + перекрывает и поднимает медиану до 6 500 000. В базисе клиента якорь 'euro' + стоит 7 000 000 / 1.05 = 6 666 667 < 6 900 000 → медиана не двигается. + """ + est = _run_estimate(_anchor(7_000_000, "euro"), None) + assert est.median_price_rub == _BASE_MEDIAN + + +def test_blend_still_fires_and_reports_the_rebased_anchor() -> None: + """Правка не глушит механизм: якорь, крупный и после пересчёта, по-прежнему блендится. + + median = 5 640 000, якорь 'cosmetic' 10 000 000 → в базисе клиента 9 400 000 + > 6 486 000 → blend = (5 640 000 + 9 400 000) / 2 = 7 520 000. В пояснении — + ТО ЖЕ число, что ушло в расчёт, и пометка про пересчёт (иначе текст спорил бы + с карточкой, где показана сырая оценка Avito). + """ + est = _run_estimate(_anchor(10_000_000, "cosmetic"), "needs_repair") + assert est.median_price_rub == (int(_BASE_MEDIAN * 0.94) + 9_400_000) // 2 + explanation = est.confidence_explanation or "" + assert "9.4 млн ₽" in explanation + assert "приведённой к состоянию ремонта квартиры" in explanation + # Карточка Avito остаётся сырой — чужое число мы не правим. + assert est.avito_imv is not None + assert est.avito_imv.recommended_price == 10_000_000