fix(tradein/estimator): honest repair-note + ratio_basis label; doc EKB-calibrated constants (LOW audit R2 #1-4) (#2505)
Some checks failed
Deploy Trade-In / test (push) Blocked by required conditions
Deploy Trade-In / build-backend (push) Blocked by required conditions
Deploy Trade-In / build-frontend (push) Blocked by required conditions
Deploy Trade-In / build-browser (push) Blocked by required conditions
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / changes (push) Has been cancelled

This commit is contained in:
bot-backend 2026-07-12 22:11:03 +00:00
parent 348be445e3
commit 7fa52bb3ad
3 changed files with 52 additions and 3 deletions

View file

@ -222,7 +222,9 @@ class AggregatedEstimate(BaseModel):
# конкретному дому за 12 мес) — другая база и другой смысл «торга», см. докстринг # конкретному дому за 12 мес) — другая база и другой смысл «торга», см. докстринг
# HouseAnalyticsKpi. # HouseAnalyticsKpi.
asking_to_sold_ratio: float | None = None # =sold/asking, ~0.720.93 asking_to_sold_ratio: float | None = None # =sold/asking, ~0.720.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 адреса ── # ── DaData enrichment (PR Q1) — on-demand для target адреса ──
# canonical_address — DaData-нормализованная форма (с улицей в short form). # canonical_address — DaData-нормализованная форма (с улицей в short form).
# house_cadnum — кадастровый номер ДОМА (для будущего matching Росреестра). # house_cadnum — кадастровый номер ДОМА (для будущего matching Росреестра).

View file

@ -1886,6 +1886,16 @@ def _band_haircut(anchor_ppm2: float) -> float:
Премиум (высокий ppm²) торгуется плотнее меньше скидка; эконом больше. Премиум (высокий ppm²) торгуется плотнее меньше скидка; эконом больше.
Пороги ЕКБ-вторички: 350k 4%; 180-350k 5%; <180k 7%. Дефолт из config. Пороги ЕКБ-вторички: 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: if anchor_ppm2 >= 350_000:
return 0.04 return 0.04
@ -2360,9 +2370,13 @@ def _price_from_inputs(
range_high = int(range_high * repair_coef) range_high = int(range_high * repair_coef)
median_ppm2 = median_ppm2 * repair_coef median_ppm2 = median_ppm2 * repair_coef
pct = round((repair_coef - 1.0) * 100) pct = round((repair_coef - 1.0) * 100)
# LOW audit #1: _REPAIR_COEF (см. предупреждение выше, строки ~262-266) —
# tunable market-эвристика с покрытием данных ~2%, НЕ data-derived величина.
# Формулировка ниже честно маркирует поправку как ориентировочную/эвристическую,
# а не как вычисленную из данных корректировку.
repair_note = ( repair_note = (
f" Цена скорректирована на состояние ремонта " f" Оценка ориентировочно скорректирована на состояние ремонта "
f"({_REPAIR_LABEL.get(repair_state, '')} {pct:+d}%)." f"(эвристика: {_REPAIR_LABEL.get(repair_state, '')} {pct:+d}%)."
) )
# Build sources_used_pre from listings + external sources # Build sources_used_pre from listings + external sources
@ -2903,6 +2917,12 @@ def _price_from_inputs(
honest_ratio = expected_sold_price / median_price honest_ratio = expected_sold_price / median_price
if abs(honest_ratio - asking_to_sold_ratio) > _RATIO_DESCRIPTOR_EPS: if abs(honest_ratio - asking_to_sold_ratio) > _RATIO_DESCRIPTOR_EPS:
asking_to_sold_ratio = honest_ratio 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 ─────────────────────────────────── # ── #oblast-D: deals-headline-fallback ───────────────────────────────────
# When the radius/anchor analog pipeline found NOTHING usable (median_ppm2 # 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: любая из таблиц может ещё не существовать в части окружений любая Best-effort: любая из таблиц может ещё не существовать в части окружений любая
ошибка (нет таблицы / SQL) деградирует в (False, None, None) и НИКОГДА не валит ошибка (нет таблицы / 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: if house_id is None:
return (False, None, None) return (False, None, None)

View file

@ -22,6 +22,19 @@
-- --
-- Dependencies: 002_core_tables.sql (house_id space). No backend schema coupling. -- Dependencies: 002_core_tables.sql (house_id space). No backend schema coupling.
-- Re-run safe: CREATE TABLE IF NOT EXISTS + idempotent upsert. -- 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; BEGIN;