From 07834b59a10fac6c2f6f37a2f606500cb6656c0f Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 27 Jun 2026 21:13:46 +0300 Subject: [PATCH] feat(tradein/estimator): manual_review recommendation flag (#2002) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Производный ФЛАГ manual_review_recommended + RU-причины: помечает оценки, которые НЕ стоит авто-оффэрить (нужна ручная оценка человеком). Триггеры: премиальный дом, высокая стоимость (>=20 млн), низкая уверенность, широкий asking-диапазон (range_high/range_low >= 1.9). За флагом estimate_manual_review_enabled. Чисто метаданные — НЕ трогает median/expected_sold/ranges, regression-gate остаётся byte-identical (зелёный). Pure helper _manual_review + unit-тесты. --- tradein-mvp/backend/app/core/config.py | 11 + tradein-mvp/backend/app/schemas/trade_in.py | 8 + tradein-mvp/backend/app/services/estimator.py | 58 +++- .../tests/test_estimator_manual_review.py | 284 ++++++++++++++++++ 4 files changed, 360 insertions(+), 1 deletion(-) create mode 100644 tradein-mvp/backend/tests/test_estimator_manual_review.py diff --git a/tradein-mvp/backend/app/core/config.py b/tradein-mvp/backend/app/core/config.py index 9c28ff12..ff7c2f9d 100644 --- a/tradein-mvp/backend/app/core/config.py +++ b/tradein-mvp/backend/app/core/config.py @@ -218,6 +218,17 @@ class Settings(BaseSettings): # ENV: ESTIMATE_CONFIDENCE_FLOOR_NO_ANALOGS. estimate_confidence_floor_no_analogs: bool = True + # ── #2002 #4: manual-review recommendation (derived FLAG, НЕ ценовой сигнал) ─ + # Помечает оценки, которые НЕ стоит авто-оффэрить — нужна ручная оценка + # человеком. Research: элит/премиум-премия unit-level и под-доверена (зависит + # от отделки/вида, чего нет в данных сделок). Триггеры: премиальный дом, + # высокая стоимость, низкая уверенность, слишком широкий диапазон цены. + # Чисто метаданные — не трогает median/expected_sold/ranges (gate byte-stable). + # False = флаг всегда выключен (manual_review_recommended=False, reasons=[]). + estimate_manual_review_enabled: bool = True + estimate_manual_review_high_value_rub: int = 20_000_000 # ≥ этого — ручная оценка + estimate_manual_review_wide_range_ratio: float = 1.9 # range_high/range_low ≥ — неопределённо + # ── #1871 P2: radius-tier (source, source_id) dedup ─────────────────────── # Radius-путь _fetch_analogs (Tier S/H/W) кэпит только per-address # (rn_addr <= MAX_ANALOGS_PER_ADDRESS), но (source, source_id)-дубли делят один diff --git a/tradein-mvp/backend/app/schemas/trade_in.py b/tradein-mvp/backend/app/schemas/trade_in.py index bdb80f49..6a93e987 100644 --- a/tradein-mvp/backend/app/schemas/trade_in.py +++ b/tradein-mvp/backend/app/schemas/trade_in.py @@ -228,6 +228,14 @@ class AggregatedEstimate(BaseModel): # контекст для ревьюера. None если дом не премиальный / MV недоступна. premium_building: bool = False premium_building_median_ppm2: int | None = None + # ── #2002 #4: manual-review recommendation (производный ФЛАГ, НЕ цена) ── + # manual_review_recommended — оценку НЕ стоит авто-оффэрить, нужна ручная + # оценка человеком (премиальный дом / высокая стоимость / низкая уверенность / + # широкий диапазон). Дефолт False — не трогает median/expected_sold/ranges. + # manual_review_reasons — человекочитаемые RU-причины для ревьюера; пусто, когда + # рекомендация не сработала (или флаг estimate_manual_review_enabled выключен). + manual_review_recommended: bool = False + manual_review_reasons: list[str] = Field(default_factory=list) # ── Параметры оценённой квартиры — нужны, чтобы восстановить карточку # при открытии оценки по ссылке (?id=), когда формы-инпута уже нет ── area_m2: float | None = None diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index 17802b7e..5be99ba2 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -35,7 +35,7 @@ from uuid import uuid4 from sqlalchemy import text from sqlalchemy.orm import Session -from app.core.config import settings +from app.core.config import Settings, settings from app.schemas.trade_in import ( AggregatedEstimate, AnalogLot, @@ -3087,6 +3087,18 @@ async def estimate_quality( sources_used=sources_used, ) + # #2002 #4: manual-review routing — производный ФЛАГ на основе уже + # вычисленных premium_building / confidence / range (asking-IQR). НЕ влияет + # на median/expected_sold/ranges — только метаданные для UI/маршрутизации. + manual_review_recommended, manual_review_reasons = _manual_review( + premium_building, + expected_sold_price, + confidence, + range_low, + range_high, + settings, + ) + return AggregatedEstimate( estimate_id=estimate_id, median_price_rub=median_price, @@ -3154,6 +3166,8 @@ async def estimate_quality( analog_tier=api_analog_tier, # type: ignore[arg-type] premium_building=premium_building, premium_building_median_ppm2=premium_building_median_ppm2, + manual_review_recommended=manual_review_recommended, + manual_review_reasons=manual_review_reasons, ) @@ -3198,6 +3212,48 @@ def _is_premium_building(db: Session, house_id: int | None) -> tuple[bool, int | return (True, int(row[0]) if row[0] is not None else None) +def _manual_review( + premium_building: bool, + expected_sold_price: int | None, + confidence: str, + range_low: int | None, + range_high: int | None, + settings: Settings, +) -> tuple[bool, list[str]]: + """#2002 #4: рекомендация ручной оценки — производный ФЛАГ, НЕ цена. + + Помечает оценки, которые НЕ стоит авто-оффэрить — нужна оценка человеком. + Research: элит/премиум-премия unit-level и под-доверена (зависит от + отделки/вида/этажа, чего нет в данных сделок). Собирает человекочитаемые + RU-причины. Чистая функция — НЕ трогает median/expected_sold/ranges + (regression-gate остаётся byte-identical). + + Триггеры (когда estimate_manual_review_enabled): + - premium_building — премиальный дом; + - expected_sold_price ≥ high_value_rub — элит-зона по абсолютной стоимости; + - confidence == "low" — низкая уверенность оценки; + - range_high / range_low ≥ wide_range_ratio — слишком широкий диапазон. + + Возвращает (recommended, reasons), где recommended == bool(reasons). + При выключенном флаге — (False, []). Деление на ноль исключено: проверка + `range_low and range_high` отсекает None/0 до деления. + """ + if not settings.estimate_manual_review_enabled: + return (False, []) + reasons: list[str] = [] + if premium_building: + reasons.append("премиальный дом — премия зависит от отделки/вида (не в данных сделок)") + high_value = settings.estimate_manual_review_high_value_rub + if expected_sold_price and expected_sold_price >= high_value: + reasons.append("высокая стоимость (≥20 млн ₽)") + if confidence == "low": + reasons.append("низкая уверенность оценки") + wide_ratio = settings.estimate_manual_review_wide_range_ratio + if range_low and range_high and range_high / range_low >= wide_ratio: + reasons.append("широкий диапазон цены") + return (bool(reasons), reasons) + + def _estimate_days_on_market( listings: list[dict[str, Any]], deals: list[dict[str, Any]] ) -> int | None: diff --git a/tradein-mvp/backend/tests/test_estimator_manual_review.py b/tradein-mvp/backend/tests/test_estimator_manual_review.py new file mode 100644 index 00000000..5baa0d4e --- /dev/null +++ b/tradein-mvp/backend/tests/test_estimator_manual_review.py @@ -0,0 +1,284 @@ +"""#2002 #4: manual-review recommendation (производный ФЛАГ, НЕ цена). + +Покрывает чистый helper `_manual_review`: + - каждый триггер в изоляции (премиальный дом / высокая стоимость / низкая + уверенность / широкий диапазон); + - комбинированный случай (несколько причин сразу); + - all-clear (нет причин → recommended False); + - граничные значения порогов (>= high_value, >= wide_ratio); + - div-guard на диапазон (range_low None/0 → без деления на ноль); + - выключенный флаг estimate_manual_review_enabled → (False, []). + +Плюс smoke-проверка дефолтов AggregatedEstimate (флаг — метаданные: сериализация +без изменений, когда ручная оценка не рекомендована). +""" + +from __future__ import annotations + +import os +from types import SimpleNamespace + +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 = "широкий диапазон цены" + + +def _settings( + *, + enabled: bool = True, + high_value: int = 20_000_000, + wide_ratio: float = 1.9, +) -> SimpleNamespace: + """Duck-typed заглушка Settings — helper читает только эти три поля.""" + return SimpleNamespace( + estimate_manual_review_enabled=enabled, + estimate_manual_review_high_value_rub=high_value, + estimate_manual_review_wide_range_ratio=wide_ratio, + ) + + +# ── Каждый триггер в изоляции ──────────────────────────────────────────────── + + +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, + settings=_settings(), + ) + 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, + settings=_settings(), + ) + 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, + settings=_settings(), + ) + 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 + settings=_settings(), + ) + assert rec is True + assert reasons == [WIDE_RANGE_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 + settings=_settings(), + ) + 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 + settings=_settings(), + ) + 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, + settings=_settings(), + ) + 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, + settings=_settings(), + ) + 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 + settings=_settings(), + ) + 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 + settings=_settings(), + ) + 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, + settings=_settings(), + ) + assert rec is False + assert WIDE_RANGE_REASON not in reasons + + +# ── Выключенный флаг ───────────────────────────────────────────────────────── + + +def test_disabled_flag_suppresses_all() -> None: + """estimate_manual_review_enabled=False → (False, []) даже когда всё триггерит.""" + rec, reasons = _manual_review( + premium_building=True, + expected_sold_price=99_000_000, + confidence="low", + range_low=10_000_000, + range_high=99_000_000, + settings=_settings(enabled=False), + ) + assert rec is False + assert 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, + settings=_settings(), + ) + 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"]))