gendesign/tradein-mvp/backend/tests/test_estimator_manual_review.py
bot-backend 45bd47fdd7 МЕРА: дедуп аналогов и пороги ручной оценки без выключателей (#2378)
Флаг estimate_dedup_analogs_enabled снят: кросс-source дедуп работает всегда.
На проде 17.09 флаг = True во всех трёх контейнерах (backend/scraper/tgbot),
ENV-оверрайда нет; фикстура бэктеста захвачена с True, поэтому пин флага в
реплее и monkeypatch в гейте больше не нужны.

Числовые пороги estimate_wide_corridor_threshold и три
estimate_manual_review_* перенесены в estimator.py константами модуля с
прежними значениями (1.2 / 20 000 000 / 1.9 / 250 000). _manual_review больше
не принимает settings. Осиротевшие комментарии Settings к уже снятым в #2475
флагам (#1871 P1.2, P2 radius-dedup) удалены.

Тесты: OFF-тест дедупа удалён; два теста, пинившие дедуп OFF ради изоляции,
получили разные площади у аналогов (разные физлоты). Регрессионный гейт и
реплей бэктеста по сделкам (1600, из них 615 радиусных) побитово те же.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 12:29:26 +05:00

339 lines
11 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""#2002 #4: manual-review recommendation (производный ФЛАГ, НЕ цена).
Покрывает чистый helper `_manual_review`:
- каждый триггер в изоляции (премиальный дом / высокая стоимость / низкая
уверенность / широкий диапазон);
- комбинированный случай (несколько причин сразу);
- all-clear (нет причин → recommended False);
- граничные значения порогов (>= high_value, >= wide_ratio);
- div-guard на диапазон (range_low None/0 → без деления на ноль).
Плюс smoke-проверка дефолтов AggregatedEstimate (флаг — метаданные: сериализация
без изменений, когда ручная оценка не рекомендована).
"""
from __future__ import annotations
import os
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
import pytest
from app.services.estimator import _manual_review
PREMIUM_REASON = "премиальный дом — премия зависит от отделки/вида (не в данных сделок)"
HIGH_VALUE_REASON = "высокая стоимость (≥20 млн ₽)"
LOW_CONF_REASON = "низкая уверенность оценки"
WIDE_RANGE_REASON = "широкий диапазон цены"
ELITE_REASON = (
"элитный/дорогой сегмент — авто-оценка консервативна: премия за отделку, "
"вид и класс дома не отражена в данных сделок, фактическая цена может быть выше"
)
# ── Каждый триггер в изоляции ────────────────────────────────────────────────
def test_premium_building_alone() -> None:
rec, reasons = _manual_review(
premium_building=True,
expected_sold_price=8_000_000,
confidence="high",
range_low=7_500_000,
range_high=8_500_000,
)
assert rec is True
assert reasons == [PREMIUM_REASON]
def test_high_value_alone() -> None:
rec, reasons = _manual_review(
premium_building=False,
expected_sold_price=25_000_000,
confidence="high",
range_low=24_000_000,
range_high=26_000_000,
)
assert rec is True
assert reasons == [HIGH_VALUE_REASON]
def test_low_confidence_alone() -> None:
rec, reasons = _manual_review(
premium_building=False,
expected_sold_price=5_000_000,
confidence="low",
range_low=4_500_000,
range_high=5_500_000,
)
assert rec is True
assert reasons == [LOW_CONF_REASON]
def test_wide_range_alone() -> None:
rec, reasons = _manual_review(
premium_building=False,
expected_sold_price=8_000_000,
confidence="high",
range_low=5_000_000,
range_high=10_000_000, # ratio 2.0 ≥ 1.9
)
assert rec is True
assert reasons == [WIDE_RANGE_REASON]
def test_elite_ppm2_alone() -> None:
"""asking_ppm2 ≥ elite_ppm2 → только элит-причина, даже когда оценка < 20 млн.
Юнит НЕ в premium-доме, expected_sold < high_value, confidence не low,
диапазон узкий — без asking-триггера остался бы без предупреждения.
"""
rec, reasons = _manual_review(
premium_building=False,
expected_sold_price=12_000_000,
confidence="high",
range_low=11_000_000,
range_high=13_000_000, # ratio ~1.18 < 1.9
asking_ppm2=300_000, # ≥ 250_000
)
assert rec is True
assert reasons == [ELITE_REASON]
def test_elite_ppm2_boundary_inclusive() -> None:
"""asking_ppm2 == elite_ppm2 → триггерит (>=)."""
rec, reasons = _manual_review(
premium_building=False,
expected_sold_price=12_000_000,
confidence="high",
range_low=11_000_000,
range_high=13_000_000,
asking_ppm2=250_000,
)
assert rec is True
assert reasons == [ELITE_REASON]
def test_elite_ppm2_just_below_threshold() -> None:
"""asking_ppm2 на 1 ₽ ниже порога → без элит-причины."""
rec, reasons = _manual_review(
premium_building=False,
expected_sold_price=12_000_000,
confidence="high",
range_low=11_000_000,
range_high=13_000_000,
asking_ppm2=249_999,
)
assert rec is False
assert reasons == []
def test_elite_ppm2_none_safe() -> None:
"""asking_ppm2 None (дефолт) → элит-триггер не срабатывает, без ошибок."""
rec, reasons = _manual_review(
premium_building=False,
expected_sold_price=12_000_000,
confidence="high",
range_low=11_000_000,
range_high=13_000_000,
asking_ppm2=None,
)
assert rec is False
assert reasons == []
assert ELITE_REASON not in reasons
def test_elite_ppm2_zero_safe() -> None:
"""asking_ppm2 == 0 (falsy) → без деления/триггера, без элит-причины."""
rec, reasons = _manual_review(
premium_building=False,
expected_sold_price=12_000_000,
confidence="high",
range_low=11_000_000,
range_high=13_000_000,
asking_ppm2=0,
)
assert rec is False
assert ELITE_REASON not in reasons
def test_elite_ppm2_combined_with_other_triggers() -> None:
"""Элит-причина аддитивна: premium + high_value + low_conf + wide + elite."""
rec, reasons = _manual_review(
premium_building=True,
expected_sold_price=30_000_000,
confidence="low",
range_low=10_000_000,
range_high=25_000_000, # ratio 2.5 ≥ 1.9
asking_ppm2=400_000,
)
assert rec is True
# порядок детерминирован: premium → high_value → low_conf → wide_range → elite
assert reasons == [
PREMIUM_REASON,
HIGH_VALUE_REASON,
LOW_CONF_REASON,
WIDE_RANGE_REASON,
ELITE_REASON,
]
# ── Комбинированный / all-clear ──────────────────────────────────────────────
def test_multiple_reasons_combined() -> None:
rec, reasons = _manual_review(
premium_building=True,
expected_sold_price=30_000_000,
confidence="low",
range_low=10_000_000,
range_high=25_000_000, # ratio 2.5 ≥ 1.9
)
assert rec is True
# порядок детерминирован: premium → high_value → low_conf → wide_range
assert reasons == [PREMIUM_REASON, HIGH_VALUE_REASON, LOW_CONF_REASON, WIDE_RANGE_REASON]
def test_all_clear_no_reasons() -> None:
rec, reasons = _manual_review(
premium_building=False,
expected_sold_price=10_000_000,
confidence="medium",
range_low=9_000_000,
range_high=11_000_000, # ratio ~1.22 < 1.9
)
assert rec is False
assert reasons == []
# ── Граничные значения порогов ───────────────────────────────────────────────
def test_high_value_boundary_inclusive() -> None:
"""expected_sold_price == high_value → триггерит (>=)."""
rec, reasons = _manual_review(
premium_building=False,
expected_sold_price=20_000_000,
confidence="medium",
range_low=19_000_000,
range_high=21_000_000,
)
assert rec is True
assert reasons == [HIGH_VALUE_REASON]
def test_high_value_just_below_threshold() -> None:
rec, reasons = _manual_review(
premium_building=False,
expected_sold_price=19_999_999,
confidence="medium",
range_low=19_000_000,
range_high=21_000_000,
)
assert rec is False
assert reasons == []
def test_wide_range_boundary_inclusive() -> None:
"""range_high/range_low == wide_ratio → триггерит (>=)."""
rec, reasons = _manual_review(
premium_building=False,
expected_sold_price=8_000_000,
confidence="high",
range_low=10_000_000,
range_high=19_000_000, # ratio == 1.9
)
assert rec is True
assert reasons == [WIDE_RANGE_REASON]
def test_wide_range_just_below_threshold() -> None:
rec, reasons = _manual_review(
premium_building=False,
expected_sold_price=8_000_000,
confidence="high",
range_low=10_000_000,
range_high=18_900_000, # ratio 1.89 < 1.9
)
assert rec is False
assert reasons == []
# ── Div-guard на диапазон ────────────────────────────────────────────────────
@pytest.mark.parametrize(
("range_low", "range_high"),
[
(None, 10_000_000),
(0, 10_000_000),
(5_000_000, None),
(None, None),
],
)
def test_wide_range_div_guard(range_low: int | None, range_high: int | None) -> None:
"""range_low None/0 (или range_high None) → без ZeroDivisionError и без причины."""
rec, reasons = _manual_review(
premium_building=False,
expected_sold_price=8_000_000,
confidence="high",
range_low=range_low,
range_high=range_high,
)
assert rec is False
assert WIDE_RANGE_REASON not in reasons
# ── invariant recommended == bool(reasons) ───────────────────────────────────
def test_recommended_equals_bool_reasons_invariant() -> None:
for premium, esp, conf in [
(False, None, "medium"),
(True, 5_000_000, "medium"),
(False, 25_000_000, "low"),
]:
rec, reasons = _manual_review(
premium_building=premium,
expected_sold_price=esp,
confidence=conf,
range_low=9_000_000,
range_high=11_000_000,
)
assert rec is bool(reasons)
# ── AggregatedEstimate default serialization smoke ───────────────────────────
def test_response_model_defaults_no_manual_review() -> None:
"""AggregatedEstimate по умолчанию: recommended=False, reasons=[]."""
from datetime import UTC, datetime
from uuid import uuid4
from app.schemas.trade_in import AggregatedEstimate
est = AggregatedEstimate(
estimate_id=uuid4(),
median_price_rub=10_000_000,
range_low_rub=9_000_000,
range_high_rub=11_000_000,
median_price_per_m2=200_000,
confidence="medium",
n_analogs=12,
period_months=24,
analogs=[],
actual_deals=[],
expires_at=datetime.now(tz=UTC),
)
assert est.manual_review_recommended is False
assert est.manual_review_reasons == []
dumped = est.model_dump()
assert dumped["manual_review_recommended"] is False
assert dumped["manual_review_reasons"] == []
if __name__ == "__main__": # pragma: no cover
raise SystemExit(pytest.main([__file__, "-q"]))