feat(tradein/estimator): manual_review recommendation flag (#2002)
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 1m30s
Deploy Trade-In / build-backend (push) Successful in 1m2s
Deploy Trade-In / deploy (push) Successful in 49s
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 1m30s
Deploy Trade-In / build-backend (push) Successful in 1m2s
Deploy Trade-In / deploy (push) Successful in 49s
This commit is contained in:
parent
c5227a164e
commit
275e7e9618
4 changed files with 360 additions and 1 deletions
|
|
@ -218,6 +218,17 @@ class Settings(BaseSettings):
|
||||||
# ENV: ESTIMATE_CONFIDENCE_FLOOR_NO_ANALOGS.
|
# ENV: ESTIMATE_CONFIDENCE_FLOOR_NO_ANALOGS.
|
||||||
estimate_confidence_floor_no_analogs: bool = True
|
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 ───────────────────────
|
# ── #1871 P2: radius-tier (source, source_id) dedup ───────────────────────
|
||||||
# Radius-путь _fetch_analogs (Tier S/H/W) кэпит только per-address
|
# Radius-путь _fetch_analogs (Tier S/H/W) кэпит только per-address
|
||||||
# (rn_addr <= MAX_ANALOGS_PER_ADDRESS), но (source, source_id)-дубли делят один
|
# (rn_addr <= MAX_ANALOGS_PER_ADDRESS), но (source, source_id)-дубли делят один
|
||||||
|
|
|
||||||
|
|
@ -228,6 +228,14 @@ class AggregatedEstimate(BaseModel):
|
||||||
# контекст для ревьюера. None если дом не премиальный / MV недоступна.
|
# контекст для ревьюера. None если дом не премиальный / MV недоступна.
|
||||||
premium_building: bool = False
|
premium_building: bool = False
|
||||||
premium_building_median_ppm2: int | None = None
|
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=), когда формы-инпута уже нет ──
|
# при открытии оценки по ссылке (?id=), когда формы-инпута уже нет ──
|
||||||
area_m2: float | None = None
|
area_m2: float | None = None
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@ from uuid import uuid4
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import Settings, settings
|
||||||
from app.schemas.trade_in import (
|
from app.schemas.trade_in import (
|
||||||
AggregatedEstimate,
|
AggregatedEstimate,
|
||||||
AnalogLot,
|
AnalogLot,
|
||||||
|
|
@ -3087,6 +3087,18 @@ async def estimate_quality(
|
||||||
sources_used=sources_used,
|
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(
|
return AggregatedEstimate(
|
||||||
estimate_id=estimate_id,
|
estimate_id=estimate_id,
|
||||||
median_price_rub=median_price,
|
median_price_rub=median_price,
|
||||||
|
|
@ -3154,6 +3166,8 @@ async def estimate_quality(
|
||||||
analog_tier=api_analog_tier, # type: ignore[arg-type]
|
analog_tier=api_analog_tier, # type: ignore[arg-type]
|
||||||
premium_building=premium_building,
|
premium_building=premium_building,
|
||||||
premium_building_median_ppm2=premium_building_median_ppm2,
|
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)
|
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(
|
def _estimate_days_on_market(
|
||||||
listings: list[dict[str, Any]], deals: list[dict[str, Any]]
|
listings: list[dict[str, Any]], deals: list[dict[str, Any]]
|
||||||
) -> int | None:
|
) -> int | None:
|
||||||
|
|
|
||||||
284
tradein-mvp/backend/tests/test_estimator_manual_review.py
Normal file
284
tradein-mvp/backend/tests/test_estimator_manual_review.py
Normal file
|
|
@ -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"]))
|
||||||
Loading…
Add table
Reference in a new issue