diff --git a/tradein-mvp/backend/app/core/config.py b/tradein-mvp/backend/app/core/config.py index d291f723..cbef4320 100644 --- a/tradein-mvp/backend/app/core/config.py +++ b/tradein-mvp/backend/app/core/config.py @@ -711,6 +711,14 @@ class Settings(BaseSettings): # ENV: ESTIMATE_UNKNOWN_ATTR_PENALTY_ENABLED. estimate_unknown_attr_penalty_enabled: bool = False estimate_unknown_attr_penalty_min_n: int = 5 + # #3234: Tier H в _fetch_analogs («тот же класс дома»). Порог отдачи и окно + # этажности были литералами; вынесены, чтобы бэктест мог сравнить варианты + # через env без правки кода. Дефолты = прежнее поведение байт-в-байт. + # Прод, Loki 30 сут (12.09): 61 из 86 попыток Tier H уходят в Tier W, в 31 из + # них в своей полосе 0 комплов. Менять дефолт — только по MAPE бэктеста. + # ENV: ESTIMATE_TIER_H_MIN_COMPS, ESTIMATE_TIER_H_FLOORS_TOL. + estimate_tier_h_min_comps: int = 5 + estimate_tier_h_floors_tol: float = 0.30 # total_floors × (1 ± tol) # # is_apartments (#2008): концептуально ОТДЕЛЬНАЯ фича — не "мягкая # корректировка", а hard-filter сегмент-guard, симметричный novostroyki-guard diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index 825c5f0f..3f07528a 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -6551,8 +6551,9 @@ def _fetch_analogs( _normalize_building_key + _house_boundary_regex, тот же принцип что и anchor Tier A) + ST_DWithin(radius_m). Если ≥3 результатов → tier='S'. - **Tier H (same class):** PostGIS + rooms + area + year ±15 + total_floors ±30%. - Если ≥5 результатов → возвращаем; tier='H'. + **Tier H (same class):** PostGIS + rooms + area + year ±15 + total_floors + ±estimate_tier_h_floors_tol (0.30). Если ≥ estimate_tier_h_min_comps (5) + результатов → возвращаем; tier='H'. Пропускается если year_built или total_floors неизвестны. **Tier W (wide / current):** текущая логика без year/floors WHERE фильтра. @@ -6747,12 +6748,12 @@ def _fetch_analogs( ) return _stratify_candidates(tier_s), radius_m > DEFAULT_RADIUS_M, "S" - # ── Tier H: same class (year ±15, total_floors ±30%) ───────────────────── + # ── Tier H: same class (year ±15, total_floors ±tol, #3234: tol/порог в settings) ─ if year_built is not None and total_floors is not None: year_min = year_built - 15 year_max = year_built + 15 - tf_min = math.floor(total_floors * 0.7) - tf_max = math.ceil(total_floors * 1.3) + tf_min = math.floor(total_floors * (1 - settings.estimate_tier_h_floors_tol)) + tf_max = math.ceil(total_floors * (1 + settings.estimate_tier_h_floors_tol)) tier_h_rows = ( db.execute( @@ -6860,7 +6861,7 @@ def _fetch_analogs( min_n=settings.estimate_unknown_attr_penalty_min_n, ) tier_h.sort(key=lambda r: r.get("relevance_score") or 0.0) - if len(tier_h) >= 5: + if len(tier_h) >= settings.estimate_tier_h_min_comps: logger.info( "analogs tier=H year=%d±15 tf=%d-%d → %d results", year_built, diff --git a/tradein-mvp/backend/tests/test_3234_tier_h_settings.py b/tradein-mvp/backend/tests/test_3234_tier_h_settings.py new file mode 100644 index 00000000..b7480b7a --- /dev/null +++ b/tradein-mvp/backend/tests/test_3234_tier_h_settings.py @@ -0,0 +1,78 @@ +"""#3234: порог и окно этажности Tier H берутся из settings, дефолты = прежние литералы. + +Tier H в _fetch_analogs отдаёт «тот же класс дома» только при ≥ N комплов, иначе +уходит в Tier W без фильтра по классу. N и окно total_floors были литералами (5 и +×0.7…×1.3); вынесены, чтобы бэктест сравнил варианты через env. Проверяем значения, +которые реально уходят в SQL, и тир, который реально возвращается. +""" + +from __future__ import annotations + +import os +from unittest.mock import MagicMock + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +import pytest + +import app.services.estimator as est + + +def _run(n_tier_h: int, total_floors: int) -> tuple[str, dict]: + """_fetch_analogs на mock-БД: Tier H отдаёт n_tier_h строк, Tier W — одну.""" + tier_h_params: dict = {} + + def side_effect(stmt, params): # type: ignore[no-untyped-def] + result = MagicMock() + if "tf_min" in params: + tier_h_params.update(params) + rows = [{"source": "avito", "relevance_score": float(i)} for i in range(n_tier_h)] + else: + rows = [{"source": "cian", "relevance_score": 0.0}] + result.mappings.return_value.all.return_value = rows + return result + + db = MagicMock() + db.execute.side_effect = side_effect + _, _, tier = est._fetch_analogs( + db, + lat=56.83, + lon=60.6, + rooms=2, + area=50.0, + radius_m=1000, + year_built=1975, + total_floors=total_floors, + ) + return tier, tier_h_params + + +def test_defaults_keep_old_literals() -> None: + tier, params = _run(n_tier_h=5, total_floors=9) + assert (params["tf_min"], params["tf_max"]) == (6, 12) # floor(6.3), ceil(11.7) + assert tier == "H" + tier, params = _run(n_tier_h=4, total_floors=16) + assert (params["tf_min"], params["tf_max"]) == (11, 21) # floor(11.2), ceil(20.8) + assert tier == "W" + + +def test_min_comps_from_settings(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(est.settings, "estimate_tier_h_min_comps", 4) + assert _run(n_tier_h=4, total_floors=9)[0] == "H" + assert _run(n_tier_h=3, total_floors=9)[0] == "W" + + +def test_floors_tol_from_settings(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(est.settings, "estimate_tier_h_floors_tol", 0.5) + _, params = _run(n_tier_h=5, total_floors=9) + assert (params["tf_min"], params["tf_max"]) == (4, 14) # floor(4.5), ceil(13.5) + + +def test_env_reaches_settings(monkeypatch: pytest.MonkeyPatch) -> None: + # Бэктест вариантов идёт через `docker exec -e ESTIMATE_TIER_H_…` — env обязан доехать. + from app.core.config import Settings + + monkeypatch.setenv("ESTIMATE_TIER_H_MIN_COMPS", "4") + monkeypatch.setenv("ESTIMATE_TIER_H_FLOORS_TOL", "0.5") + s = Settings() + assert (s.estimate_tier_h_min_comps, s.estimate_tier_h_floors_tol) == (4, 0.5)