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

This commit is contained in:
lekss361 2026-06-27 18:17:24 +00:00
parent c5227a164e
commit 275e7e9618
4 changed files with 360 additions and 1 deletions

View file

@ -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)-дубли делят один

View file

@ -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

View file

@ -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:

View 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"]))