From 6f6224f903327b5bed7f75c1b517c06bcef6fbf3 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Mon, 13 Jul 2026 01:04:01 +0300 Subject: [PATCH] fix(tradein/estimator): honest repair-note + ratio_basis label; document EKB-calibrated band/premium constants - repair-note: reworded to signal heuristic adjustment (coverage ~2%, not data-derived) - ratio_basis: set 'expected_over_median' when honest_ratio overwrites the raw per-rooms ratio (#2141 descriptor) - _band_haircut: doc EKB-secondary calibration constants applied engine-wide (no behavior change) - premium_buildings_curated: doc EKB-scope; oblast-elite still caught by _manual_review value thresholds LOW audit R2 (#1-4). Regression gate byte-green. --- tradein-mvp/backend/app/schemas/trade_in.py | 4 +- tradein-mvp/backend/app/services/estimator.py | 38 ++++++++++++++++++- .../sql/142_premium_buildings_curated.sql | 13 +++++++ 3 files changed, 52 insertions(+), 3 deletions(-) diff --git a/tradein-mvp/backend/app/schemas/trade_in.py b/tradein-mvp/backend/app/schemas/trade_in.py index 7ac938f8..c47b8e39 100644 --- a/tradein-mvp/backend/app/schemas/trade_in.py +++ b/tradein-mvp/backend/app/schemas/trade_in.py @@ -222,7 +222,9 @@ class AggregatedEstimate(BaseModel): # конкретному дому за 12 мес) — другая база и другой смысл «торга», см. докстринг # HouseAnalyticsKpi. asking_to_sold_ratio: float | None = None # =sold/asking, ~0.72–0.93 - ratio_basis: str | None = None # 'per_rooms' | 'global_fallback' + # 'per_rooms' | 'global_fallback' | 'expected_over_median' (LOW audit #2: set when + # #2141's honest_ratio recompute overwrites the raw ratio_resolver value below). + ratio_basis: str | None = None # ── DaData enrichment (PR Q1) — on-demand для target адреса ── # canonical_address — DaData-нормализованная форма (с улицей в short form). # house_cadnum — кадастровый номер ДОМА (для будущего matching Росреестра). diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index 290305fd..953d5a57 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -1886,6 +1886,16 @@ def _band_haircut(anchor_ppm2: float) -> float: Премиум (высокий ppm²) торгуется плотнее → меньше скидка; эконом — больше. Пороги ЕКБ-вторички: ≥350k → 4%; 180-350k → 5%; <180k → 7%. Дефолт из config. + + LOW audit #3: 0.04/0.07 (и mid из settings.asking_to_sold_haircut) — + EKB-secondary-market calibration constants, но применяются ENGINE-WIDE (нет + city-параметра ни здесь, ни у единственного вызывающего + `_compute_same_building_anchor`). Реального импакта на не-ЕКБ область пока нет + (same-building anchor pool для oblast сейчас не формируется — anchor_ppm2 сюда + просто не доходит), но это доверие к отсутствию данных, а не к дизайну. Как + только oblast anchor pools появятся (см. #oblast-D fallback выше), эти пороги + нужно пересмотреть/сделать per-city — не оставлять ЕКБ-калибровку по умолчанию + для другого рынка. No behavior change here (doc-only). """ if anchor_ppm2 >= 350_000: return 0.04 @@ -2360,9 +2370,13 @@ def _price_from_inputs( range_high = int(range_high * repair_coef) median_ppm2 = median_ppm2 * repair_coef pct = round((repair_coef - 1.0) * 100) + # LOW audit #1: _REPAIR_COEF (см. предупреждение выше, строки ~262-266) — + # tunable market-эвристика с покрытием данных ~2%, НЕ data-derived величина. + # Формулировка ниже честно маркирует поправку как ориентировочную/эвристическую, + # а не как вычисленную из данных корректировку. repair_note = ( - f" Цена скорректирована на состояние ремонта " - f"({_REPAIR_LABEL.get(repair_state, '')} {pct:+d}%)." + f" Оценка ориентировочно скорректирована на состояние ремонта " + f"(эвристика: {_REPAIR_LABEL.get(repair_state, '')} {pct:+d}%)." ) # Build sources_used_pre from listings + external sources @@ -2903,6 +2917,12 @@ def _price_from_inputs( honest_ratio = expected_sold_price / median_price if abs(honest_ratio - asking_to_sold_ratio) > _RATIO_DESCRIPTOR_EPS: asking_to_sold_ratio = honest_ratio + # LOW audit #2: once honest_ratio overwrites the raw per_rooms/ + # global_fallback ratio, `ratio_basis` (still the pre-shift + # ratio_resolver provenance) mislabels the value's origin. Relabel + # to an honest, self-describing basis so consumers (API/PDF) don't + # attribute expected_over_median math to a stale per-rooms lookup. + ratio_basis = "expected_over_median" # ── #oblast-D: deals-headline-fallback ─────────────────────────────────── # When the radius/anchor analog pipeline found NOTHING usable (median_ppm2 @@ -3913,6 +3933,20 @@ def _is_premium_building(db: Session, house_id: int | None) -> tuple[bool, int | Best-effort: любая из таблиц может ещё не существовать в части окружений — любая ошибка (нет таблицы / SQL) деградирует в (False, None, None) и НИКОГДА не валит оценку. + + LOW audit #4 — EKB-bias (doc-only, no behavior change): и `premium_buildings_curated` + (data/sql/142, см. header-комментарий там), и её MV-фолбэк `premium_houses` + (data/sql/139) построены и выверены по ЕКБ-методологии. Кураторская таблица + активно ДЕПРОМОУТИТ non-EKB описания как шум/mislabel (см. пример house_id=372944 + в 142_premium_buildings_curated.sql — «это НЕ ЕКБ» → is_premium=false). Значит для + областных (oblast) элитных домов эта функция систематически вернёт (False, None, + None) — premium_building флаг не выставится, даже если дом объективно элитный по + рынку своего города. Это не тихая потеря сигнала: `_manual_review` (см. ниже, + строки ~3944-3993) всё равно ловит такие объекты по АБСОЛЮТНОЙ стоимости — + `estimate_manual_review_high_value_rub` (expected_sold_price ≥ порога) и + `estimate_manual_review_elite_ppm2` (asking ₽/м² ≥ порога) — независимо от + premium_building. То есть oblast-элита не помечается как «премиальный дом», но + почти всегда подхватывается value-based триггером той же функции. """ if house_id is None: return (False, None, None) diff --git a/tradein-mvp/backend/data/sql/142_premium_buildings_curated.sql b/tradein-mvp/backend/data/sql/142_premium_buildings_curated.sql index 80772d7d..0809264a 100644 --- a/tradein-mvp/backend/data/sql/142_premium_buildings_curated.sql +++ b/tradein-mvp/backend/data/sql/142_premium_buildings_curated.sql @@ -22,6 +22,19 @@ -- -- Dependencies: 002_core_tables.sql (house_id space). No backend schema coupling. -- Re-run safe: CREATE TABLE IF NOT EXISTS + idempotent upsert. +-- +-- LOW audit #4 — EKB scope (doc-only, no behavior change): this seed and its +-- `premium_houses` MV fallback (data/sql/139) are both curated/calibrated against +-- Ekaterinburg secondary-market listings ONLY. Non-EKB descriptions encountered +-- during curation were treated as noise and actively DEMOTED (is_premium=false) — +-- e.g. house_id=372944 below ("г. Нижний Новгород, Александровская слобода... это +-- НЕ ЕКБ" — mislabeled, not premium). Consequence: an oblast (non-EKB) elite +-- building will NOT receive the `premium_building` flag from this table or its MV +-- fallback — the estimator's `_is_premium_building` (app/services/estimator.py) +-- has no per-city variant of this overlay. This is not a silent gap: the estimator's +-- `_manual_review` still routes genuinely high-value objects to manual review via +-- absolute-value thresholds (estimate_manual_review_high_value_rub, +-- estimate_manual_review_elite_ppm2) independent of the premium_building flag. BEGIN;