feat(tradein): AI-curated premium-building overlay — class + false-positive demotion (#2002) #2021
4 changed files with 314 additions and 62 deletions
|
|
@ -220,14 +220,19 @@ class AggregatedEstimate(BaseModel):
|
|||
# НЕ удаляет/заменяет confidence_explanation (фронт fallback'ает на него).
|
||||
analog_tier: Literal["same_building", "micro_radius", "district", "city"] | None = None
|
||||
# ── #2002: премиальный дом (флаг, НЕ ценовой сигнал) ──
|
||||
# premium_building — целевой дом найден в materialized view `premium_houses`
|
||||
# (data/sql/139): ~298 EKB-домов, отобранных по концентрации дорогих листингов.
|
||||
# Это МЕТАДАННЫЕ для manual-review routing (#4) — НЕ влияет на median/expected_sold/
|
||||
# ranges. Дефолт False (поведение/сериализация без изменений, когда не премиум).
|
||||
# premium_building — целевой дом признан премиальным. Источник — curated overlay
|
||||
# `premium_buildings_curated` (data/sql/142, AI/human-выверенный класс + false-
|
||||
# positive demotions), с fallback на MV `premium_houses` (data/sql/139, ~298
|
||||
# EKB-домов по концентрации дорогих листингов) для невыверенных домов. Значение —
|
||||
# уже скорректированное (curated перебивает MV). МЕТАДАННЫЕ для manual-review
|
||||
# routing (#4) — НЕ влияет на median/expected_sold/ranges. Дефолт False.
|
||||
# premium_building_median_ppm2 — med_ppm2 из MV (₽/м² медиана дорогих листингов дома),
|
||||
# контекст для ревьюера. None если дом не премиальный / MV недоступна.
|
||||
# premium_building_class — класс дома из curated overlay (премиум/элит/бизнес/
|
||||
# комфорт/эконом/неизвестно). None для невыверенных MV-домов / непремиальных.
|
||||
premium_building: bool = False
|
||||
premium_building_median_ppm2: int | None = None
|
||||
premium_building_class: str | None = None
|
||||
# ── #2002 #4: manual-review recommendation (производный ФЛАГ, НЕ цена) ──
|
||||
# manual_review_recommended — оценку НЕ стоит авто-оффэрить, нужна ручная
|
||||
# оценка человеком (премиальный дом / высокая стоимость / низкая уверенность /
|
||||
|
|
|
|||
|
|
@ -3052,10 +3052,15 @@ async def estimate_quality(
|
|||
else None
|
||||
)
|
||||
|
||||
# #2002: премиальный-дом флаг из MV `premium_houses` для target_house_id.
|
||||
# #2002: премиальный-дом флаг + класс для target_house_id. Источник — curated
|
||||
# overlay (data/sql/142) с fallback на MV `premium_houses` (data/sql/139).
|
||||
# МЕТАДАННЫЕ (manual-review routing #4), НЕ ценовой сигнал — не трогает
|
||||
# median/expected_sold/ranges. Best-effort: degrade в (False, None) при отсутствии MV.
|
||||
premium_building, premium_building_median_ppm2 = _is_premium_building(db, target_house_id)
|
||||
# median/expected_sold/ranges. Best-effort: degrade в (False, None, None) при
|
||||
# отсутствии таблиц. premium_building_class — класс из curated (None для
|
||||
# невыверенных MV-домов).
|
||||
premium_building, premium_building_median_ppm2, premium_building_class = _is_premium_building(
|
||||
db, target_house_id
|
||||
)
|
||||
|
||||
# #audit-2: структурный analog_tier — стабильный enum для фронта.
|
||||
# anchor-путь: anchor_tier "A" → "same_building", "C" → "micro_radius".
|
||||
|
|
@ -3166,6 +3171,7 @@ async def estimate_quality(
|
|||
analog_tier=api_analog_tier, # type: ignore[arg-type]
|
||||
premium_building=premium_building,
|
||||
premium_building_median_ppm2=premium_building_median_ppm2,
|
||||
premium_building_class=premium_building_class,
|
||||
manual_review_recommended=manual_review_recommended,
|
||||
manual_review_reasons=manual_review_reasons,
|
||||
)
|
||||
|
|
@ -3182,34 +3188,54 @@ def _qc_geo_to_precision(qc_geo: int | None) -> str | None:
|
|||
return "approximate"
|
||||
|
||||
|
||||
def _is_premium_building(db: Session, house_id: int | None) -> tuple[bool, int | None]:
|
||||
"""#2002: флаг «целевой дом — премиальный» из MV `premium_houses` (data/sql/139).
|
||||
def _is_premium_building(db: Session, house_id: int | None) -> tuple[bool, int | None, str | None]:
|
||||
"""#2002: премиальный-дом флаг + класс для target_house_id.
|
||||
|
||||
Источник истины — двухслойный overlay:
|
||||
1. `premium_buildings_curated` (data/sql/142) — AI/human-выверенный слой:
|
||||
per-house class tier (премиум/элит/бизнес/комфорт/эконом/неизвестно) +
|
||||
is_premium с false-positive demotions. Если дом есть здесь — он ПЕРЕБИВАЕТ MV.
|
||||
2. `premium_houses` MV (data/sql/139) — ~298 EKB-домов по концентрации дорогих
|
||||
листингов; даёт med_ppm2 (₽/м² медиана дорогих листингов) + fallback-членство
|
||||
для НЕвыверенных домов.
|
||||
|
||||
Возвращает (is_premium, med_ppm2, building_class):
|
||||
- curated-дом → (curated.is_premium, MV.med_ppm2 или None, curated.tier);
|
||||
- невыверенный дом в MV → (True, MV.med_ppm2, None) — класс неизвестен;
|
||||
- дом нигде не найден / house_id None → (False, None, None).
|
||||
|
||||
`premium_houses` — ~298 EKB-домов, отобранных по концентрации дорогих листингов.
|
||||
Это МЕТАДАННЫЕ для manual-review routing (#4), НЕ ценовой сигнал: не трогает
|
||||
median/expected_sold/ranges. Возвращает (is_premium, med_ppm2) где med_ppm2 —
|
||||
медиана ₽/м² дорогих листингов дома (контекст для ревьюера) или None.
|
||||
median/expected_sold/ranges.
|
||||
|
||||
Best-effort: MV может ещё не существовать в части окружений — любая ошибка
|
||||
(нет MV / SQL) деградирует в (False, None) и НИКОГДА не валит оценку.
|
||||
Best-effort: любая из таблиц может ещё не существовать в части окружений — любая
|
||||
ошибка (нет таблицы / SQL) деградирует в (False, None, None) и НИКОГДА не валит
|
||||
оценку.
|
||||
"""
|
||||
if house_id is None:
|
||||
return (False, None)
|
||||
return (False, None, None)
|
||||
try:
|
||||
row = db.execute(
|
||||
cur = db.execute(
|
||||
text(
|
||||
"SELECT is_premium, tier FROM premium_buildings_curated "
|
||||
"WHERE house_id = CAST(:hid AS bigint)"
|
||||
),
|
||||
{"hid": house_id},
|
||||
).first()
|
||||
mv = db.execute(
|
||||
text("SELECT med_ppm2 FROM premium_houses WHERE house_id = CAST(:hid AS bigint)"),
|
||||
{"hid": house_id},
|
||||
).first()
|
||||
except Exception as exc: # pragma: no cover — defensive (MV может ещё не существовать)
|
||||
logger.warning("premium_houses lookup failed (graceful): %s", exc)
|
||||
except Exception as exc: # pragma: no cover — defensive (таблица может ещё не существовать)
|
||||
logger.warning("premium-building lookup failed (graceful): %s", exc)
|
||||
try:
|
||||
db.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
return (False, None)
|
||||
if row is None:
|
||||
return (False, None)
|
||||
return (True, int(row[0]) if row[0] is not None else None)
|
||||
return (False, None, None)
|
||||
med = int(mv[0]) if mv is not None and mv[0] is not None else None
|
||||
if cur is not None: # curated overlay перебивает MV (класс + corrected флаг)
|
||||
return (bool(cur[0]), med, str(cur[1]))
|
||||
return (mv is not None, med, None) # невыверенный → членство в MV, класс неизвестен
|
||||
|
||||
|
||||
def _manual_review(
|
||||
|
|
|
|||
132
tradein-mvp/backend/data/sql/142_premium_buildings_curated.sql
Normal file
132
tradein-mvp/backend/data/sql/142_premium_buildings_curated.sql
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
-- 142_premium_buildings_curated.sql
|
||||
-- Purpose: AI-curated premium-building overlay for the trade-in estimator (#2002).
|
||||
-- Overrides the concentration-based `premium_houses` MV (data/sql/139) with
|
||||
-- human/AI-verified class labels + false-positive demotions.
|
||||
--
|
||||
-- Rationale (#2002):
|
||||
-- `premium_houses` flags a house premium purely by concentration of expensive
|
||||
-- listings — which mislabels mass-market houses with a few inflated lots, and
|
||||
-- non-EKB / data-quality-noise listings, as premium. This table is an AI curation
|
||||
-- of the top premium_houses candidates: 69 confirmed premium (with class tier) +
|
||||
-- 19 false-positives demoted (is_premium=false: mass-market / non-EKB mislabels).
|
||||
-- The estimator (`_is_premium_building`) consults THIS table FIRST and falls back
|
||||
-- to the MV only for uncurated house_ids — so a curated row both corrects the
|
||||
-- premium flag and supplies a class tier (премиум/элит/бизнес/комфорт/эконом).
|
||||
--
|
||||
-- This is METADATA for manual-review routing — it does NOT touch
|
||||
-- median/expected_sold/ranges (the backtest regression gate stays byte-identical).
|
||||
--
|
||||
-- Seed: 88 curated rows (house_id, tier, is_premium, confidence, note=left(evidence,200)).
|
||||
-- ON CONFLICT ... DO UPDATE — curated reference data, re-applied harmlessly on every
|
||||
-- deploy (idempotent; the latest curation always wins).
|
||||
--
|
||||
-- Dependencies: 002_core_tables.sql (house_id space). No backend schema coupling.
|
||||
-- Re-run safe: CREATE TABLE IF NOT EXISTS + idempotent upsert.
|
||||
|
||||
BEGIN;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS premium_buildings_curated (
|
||||
house_id BIGINT PRIMARY KEY,
|
||||
tier TEXT NOT NULL,
|
||||
is_premium BOOLEAN NOT NULL,
|
||||
confidence TEXT NOT NULL,
|
||||
note TEXT,
|
||||
curated_at DATE DEFAULT CURRENT_DATE
|
||||
);
|
||||
|
||||
INSERT INTO premium_buildings_curated (house_id, tier, is_premium, confidence, note) VALUES
|
||||
(11370, 'премиум', true, 'high', 'desc: ЖК «Гоголя 21», окна в пол, панорамные виды, потолки 3.0м; ppm²=422k (>350k премиум).'),
|
||||
(12706, 'бизнес', true, 'high', 'desc: ЖК «Клевер парк» (Брусника, бизнес-класс), дизайнерский проект; ppm²=268k.'),
|
||||
(6769, 'бизнес', true, 'high', 'desc: флагманский проект НКС-Девелопмент, лучшее расположение в центре; ppm²=251k.'),
|
||||
(13000, 'премиум', true, 'high', 'desc: DOM 7 — премиум-класс от Практики у Екатеринбург-Сити; ppm²=403k (>350k).'),
|
||||
(376251, 'неизвестно', false, 'low', 'numeric addr, no ЖК; descs — спам (обмен на авто, студия ЖК zoom на Неве/СПб), нерелевантно; ppm²=169k низкий — mass-market с задранными лотами.'),
|
||||
(6685, 'бизнес', true, 'high', 'desc: Квартал «На Некрасова», новый дом бизнес-класса; ppm²=267k.'),
|
||||
(7193, 'бизнес', true, 'high', 'desc: ЖК бизнес-класса «Клевер Парк», видовая, дизайнерская эстетика; ppm²=271k.'),
|
||||
(6976, 'бизнес', true, 'high', 'desc: Квартал на Некрасова, тихий Центр; ppm²=295k (бизнес/элит).'),
|
||||
(7109, 'элит', true, 'high', 'desc: небоскрёб «Башня Исеть», Екатеринбург-Сити, видовые апартаменты 226-292м²; ppm²=415k.'),
|
||||
(372575, 'элит', true, 'high', 'desc: Клубный дом инженера Ятеса, элитный, TEN девелопмент, «Екатерининский парк»; ppm²=379k (numeric addr но desc однозначна).'),
|
||||
(6902, 'бизнес', true, 'medium', 'descs null; ppm² 236504 (бизнес-диапазон 160-250k), n_elite 35; ВИЗ/Репина — кластер бизнес-новостроек (Clever Park/Repin), но конкретный дом 13Ш веб не подтвердил однозначно.'),
|
||||
(8098, 'бизнес', true, 'high', 'desc прямо: ЖК «Парк Столиц» «инфраструктура уровня business-class»; ppm² 235234 (бизнес), n_elite 29.'),
|
||||
(6776, 'бизнес', true, 'medium', 'desc: ЖК «Никольский» «жизнь бизнес-класса в центральном районе»; ppm² 194600 (низ бизнес-диапазона), n_elite 29; адрес — район, не дом.'),
|
||||
(8125, 'бизнес', true, 'high', 'desc прямо: ЖК «Нагорный» бизнес-класса, сдан; ppm² 243523 (бизнес), n_elite 28.'),
|
||||
(6694, 'премиум', true, 'high', 'desc: «Башня Исеть», 37 этаж, окна в пол, вид на Исеть — флагман центра; ppm² 420517 (>350k премиум), n_elite 27/27.'),
|
||||
(373396, 'бизнес', true, 'medium', 'desc: ЖК «Атлас» (Atlas Development, «жилая экосистема»); ppm² 238327 (бизнес), n_elite 26; Atlas делает комфорт+/бизнес — линия бизнес по ценнику.'),
|
||||
(372944, 'комфорт', false, 'medium', 'descs про «г. Нижний Новгород, Александровская слобода» и кирпичный дом 1902г — это НЕ ЕКБ; ppm² 132794 (комфорт), клубность не премиальная — mislabeled.'),
|
||||
(6958, 'бизнес', true, 'low', 'адрес — улица без дома (агрегация); descs противоречивы (комфорт-студия «Белорецкий» + бизнес «Александровский сад»), но ppm² 293488 (элит-уровень) и n_elite 25 указывают на дорогие лоты (на улице ест'),
|
||||
(12209, 'бизнес', true, 'high', 'desc: апарт-комплекс «RE:VOLUTION TOWERS», «элитный/видовой апартамент, панорамный вид»; ppm² 236624 (бизнес), n_elite 25 — премиальные апартаменты центра.'),
|
||||
(12897, 'бизнес', true, 'medium', 'desc: ЖК «Южные кварталы» застройщик Брусника; ppm² 191k попадает в бизнес-полосу (160-250k), хотя проект масштабный комфорт+.'),
|
||||
(12603, 'элит', true, 'high', 'desc: ЖК «Александровский сад», центр, 3м потолки; ppm² 327k — элитная полоса (250-350k).'),
|
||||
(6834, 'элит', true, 'low', 'ppm² 254k (элит) + центр, но descs null и адрес без улицы/ЖК — низкая уверенность.'),
|
||||
(11682, 'премиум', true, 'high', 'desc: ЖК «Форум Сити» (бот.сад, закрытый двор), ppm² 356k — премиум-полоса (>350k).'),
|
||||
(372939, 'эконом', false, 'medium', 'desc: дом 1984г, газа нет, косметический ремонт, «удачная планировка» — масс-маркет; ppm² 119k (эконом). Ложный флаг по концентрации лотов.'),
|
||||
(10919, 'премиум', true, 'high', 'desc: Башня Исеть, Екатеринбург Сити, 18 этаж — флагман; ppm² 401k (премиум >350k).'),
|
||||
(6892, 'бизнес', true, 'low', 'desc упоминает бизнес-класс ЖК «Русь XXI» (TEN) но и комфорт «Дружный-3»; ppm² 175k (бизнес-низ) — агрегат района, смешанный.'),
|
||||
(6909, 'премиум', true, 'medium', 'ppm² 386k (премиум) + центр, Попова 18; descs null, но уровень цены однозначно премиум.'),
|
||||
(6712, 'премиум', true, 'high', 'desc явно: ЖК «Форум Сити» премиум-класса (дом 9); ppm² 385k (премиум). Тот же комплекс что hid 11682.'),
|
||||
(11765, 'элит', true, 'high', 'desc: дом бизнес-класса, пентхаус 5.7м потолки, дизайнерский ремонт; ppm² 304k (элит); тот же адрес ЖК «Александровский сад».'),
|
||||
(7706, 'бизнес', true, 'medium', 'desc: квартал «На Некрасова» (Брусника) — дома переменной высотности, тихие дворы, подземный паркинг, террасы; ppm2 244k = бизнес уровень.'),
|
||||
(12285, 'бизнес', true, 'high', 'desc явно: Компаунд «Проспект Мира», премиум-сегмент, монолит 2024, дизайнерский ремонт, мастер-спальни; ppm2 230k = бизнес/элит.'),
|
||||
(7192, 'премиум', true, 'high', 'desc: клубный дом «Литературный» в центре; ppm2 543k — премиум уровень.'),
|
||||
(372211, 'премиум', true, 'high', 'desc: клубный дом 19/05 «премиум-класса», видовая, ресепшн+служба безопасности; ppm2 442k = премиум (addr numeric, но desc однозначен).'),
|
||||
(12480, 'премиум', true, 'high', 'desc: клубный дом премиум-класса «Ленина 8», 28-эт монолит-кирпич 2024, дизайнерский ремонт; ppm2 389k = премиум.'),
|
||||
(9401, 'комфорт', false, 'medium', 'web: Цвиллинга 7 = ЖК «Парковый квартал 2.0» (Унистрой), комфорт-класс; высокий ppm2 211k и 15 элитных лотов — концентрация дорогих лотов в массовом доме.'),
|
||||
(372938, 'бизнес', true, 'low', 'ppm2 200k = бизнес-уровень, но addr numeric и descs null — нет ЖК/класс-слов для подтверждения.'),
|
||||
(11052, 'элит', true, 'high', 'web: ЖК «Гоголя 21» (Брусника) — элитный класс, лобби/библиотека/фитнес в стилобате; ppm2 417k подтверждает.'),
|
||||
(6978, 'элит', true, 'high', 'desc: ЖК «FORUM CITY» центр, эксклюзивная 7-комн 273 м², дизайнерский ремонт; ppm2 289k = элит уровень.'),
|
||||
(372937, 'комфорт', false, 'low', 'единственный desc массовый — «однушка переделанная в двушку, мебель/техника остаётся, тамбур с полками»; addr numeric; высокий ppm2 243k вероятно от мелкой площади в старом фонде, не бизнес-дом.'),
|
||||
(11541, 'бизнес', true, 'medium', 'desc: ЖК «Парковый квартал 2.0» (Брусника, позиционируется комфорт+), но ppm² 241k и 14/19 elite-лотов — пограничный бизнес-уровень цены.'),
|
||||
(6963, 'бизнес', true, 'high', 'desc прямо: ЖК «Парк Столиц» бизнес-класс; ppm² 227k подтверждает бизнес-band.'),
|
||||
(8153, 'премиум', true, 'high', 'desc: клубный дом «На Октябрьской», панорама на Исеть, премиум-сервис; ppm² 398k > 350k = премиум.'),
|
||||
(137631, 'бизнес', true, 'high', 'desc: бизнес-квартал «Нагорный», панорамные окна, топ-застройщик; ppm² 240k = бизнес.'),
|
||||
(177700, 'комфорт', false, 'high', 'desc: жилой район «Южные кварталы» (ЛСР, массовый комфорт), предчистовая отделка + скидки; ppm² 193k завышен лишь точечно, n=95 — масс-маркет.'),
|
||||
(7560, 'комфорт', false, 'medium', 'desc: ЖК «Соната Парк» (Астон, комфорт), массовые скидочные тексты; ppm² 167k низ бизнес-границы, generic-addr корзина n=344.'),
|
||||
(373406, 'бизнес', true, 'medium', 'desc прямо: ЖК «Никольский» жизнь бизнес-класса в центре; ppm² 272k подтверждает; addr только числовой.'),
|
||||
(373413, 'бизнес', true, 'low', 'descs=null, addr числовой — нет ЖК/класс-слов; вывод только по ppm² 250k (бизнес/элит-граница) + 12 elite-лотов.'),
|
||||
(372920, 'эконом', false, 'medium', 'ppm² 111k = эконом; descs относятся к Челябинску (панельный дом, студия) — data-quality шум, не ЕКБ-премиум; elite-лоты = крупные дешёвые по ppm².'),
|
||||
(12747, 'премиум', true, 'high', 'desc: клубный дом «19/05» премиум-класса в центре у набережной Исети, видовая; ppm² 444k > 350k = премиум.'),
|
||||
(8264, 'элит', true, 'medium', 'ppm² 262k (элит-диапазон), 11/13 лотов ≥20М; desc — ЖК На Некрасова, арт-деко, дизайн Vision Interieure, Derufa premium → дизайнерский премиум-проект.'),
|
||||
(10878, 'бизнес', true, 'medium', 'ppm² 247k (бизнес/элит граница), 11/14 ≥20М; desc — кирпичный спецпроект, 18 этажей, просторные планировки → бизнес-класс.'),
|
||||
(6843, 'бизнес', true, 'high', 'desc прямо — ЖК бизнес-класса «Отдельный, 5» у УРФУ; ppm² 240k подтверждает бизнес.'),
|
||||
(372948, 'комфорт', false, 'medium', 'desc — ЖК ЯрСити (массовый комфорт-проект Брусники); ppm² 157k = комфорт, перегрет отдельными лотами с ремонтом «для себя».'),
|
||||
(372954, 'эконом', false, 'medium', 'ppm² 115k (эконом), n=106 массовая выборка; desc — квартира-студия у Пл. Маркса, типовой массмаркет, 11/106 дорогих лотов случайны.'),
|
||||
(9860, 'премиум', true, 'high', 'ppm² 552k — premium-уровень (>350k), все 10/10 лотов ≥20М; центр, Первомайская 7 → клубный/премиум дом (desc нет, но ppm² однозначен).'),
|
||||
(12116, 'элит', true, 'high', 'ppm² 291k (элит); desc — ЖК «Мечта» бизнес-класс в центре, дизайнерский ремонт, престижный → премиум.'),
|
||||
(8248, 'бизнес', true, 'high', 'desc — ЖК бизнес-класса «Отдельный, 5» у УРФУ (тот же объект, что hid 6843); ppm² 253k подтверждает.'),
|
||||
(8065, 'бизнес', true, 'high', 'desc — ЖК «Clever Park» (Брусника), панорамные окна, вид на набережную Исети, новый дом; ppm² 251k = бизнес-класс.'),
|
||||
(7041, 'бизнес', true, 'low', 'ppm² 228k (бизнес-уровень), закрытая территория, подземный паркинг, охрана; desc продавца называет «комфорт класса» — противоречие, но медианный ppm² тянет к бизнесу.'),
|
||||
(9878, 'элит', true, 'high', 'desc: Клубный дом «Тихвинъ» — родоначальник элитного сегмента ЕКБ; ppm² 472k (премиум-уровень), 9/9 elite.'),
|
||||
(9754, 'премиум', true, 'medium', 'ppm² 434k (>350k премиум), центр ЕКБ, 9/9 elite; no desc but price level unambiguous.'),
|
||||
(12703, 'бизнес', true, 'high', 'desc явно «бизнес-класса ЖК Клевер парк»; ppm² 305k, новый центр ЕКБ.'),
|
||||
(373331, 'комфорт', false, 'medium', 'desc: ЖК «Парковый квартал 2.0 — комфорт-класс»; ppm² 212k inflated by few lots (9/42 elite), mass-market.'),
|
||||
(12208, 'бизнес', true, 'medium', 'desc: «статусный камерный дом», престижный район, видовая, огороженная территория; ppm² 206k = бизнес.'),
|
||||
(6669, 'комфорт', false, 'medium', 'desc: квартал «Лайв» (Брусника LIVE) — комфорт-класс; only 9/73 elite, premium by concentration not reality.'),
|
||||
(12596, 'комфорт', false, 'low', 'desc: ЖК «Северные Кварталы» от Брусники, урбан-вилла формат — комфорт/комфорт+; ppm² 204k, 9/28 elite.'),
|
||||
(372927, 'комфорт', false, 'medium', 'desc: ЖК «Домино» комфорт+ / ЖК «Гулливер»; ppm² 121k = комфорт baseline, mass-market.'),
|
||||
(372930, 'комфорт', false, 'medium', 'desc: ЖК «Октябрьский» — и упоминается Тюмень (не ЕКБ); ppm² 114k эконом-комфорт, предчистовая отделка.'),
|
||||
(9750, 'бизнес', true, 'high', 'desc: ЖК «Макаровский» (флагман набережной ЕКБ); ppm² 425k, потолки 3.2м, 8/10 elite.'),
|
||||
(6747, 'бизнес', true, 'high', 'desc: ЖК «Архитектон», прямо «Бизнес класс»; ppm² 233k (бизнес-диапазон).'),
|
||||
(6896, 'бизнес', true, 'medium', 'desc: квартал «Тихий центр» (Brusnika) «тихая роскошь в сердце ЕКБ»; ppm² 227k = бизнес.'),
|
||||
(6930, 'комфорт', false, 'high', 'desc прямо: дом комфорт-класса «Чемпион-парк»; ppm² 200k завышен отдельными видовыми лотами, класс comfort.'),
|
||||
(375422, 'эконом', false, 'medium', 'desc: квартира-студия от хозяина, «Черемушки 4» застройщик Шэлдом от 5.8М; ppm² 113k = эконом.'),
|
||||
(8158, 'элит', true, 'high', 'desc: «резиденция в ЖК Макаровский Элит», премиум-класс; ppm² 700k — топ элит/премиум.'),
|
||||
(8161, 'премиум', true, 'high', 'desc: ЖК «Макаровский» бизнес-класс на набережной; ppm² 428k = премиум-уровень.'),
|
||||
(9522, 'премиум', true, 'medium', 'descs null, но ppm² 413k (премиум >350k) при n_elite 7/8 — устойчиво премиальный дом в центре.'),
|
||||
(12316, 'премиум', true, 'medium', 'descs null; ppm² 370k премиум; Народной Воли (наб. Исети) — зона клубных домов (Most).'),
|
||||
(7189, 'премиум', true, 'high', 'desc: «Клубный Дом Most премиум класса», резиденция, видовая на набережную Исети; ppm² 370k.'),
|
||||
(12700, 'бизнес', true, 'high', 'desc: ЖК бизнес-класса «Клевер Парк», видовая, дизайнерская, у парка Маяковского; ppm² 306k.'),
|
||||
(12746, 'элит', true, 'high', 'desc: «самый статусный и узнаваемый дом города», ЖК «Февральская Революция», набережная, однопод., охрана; ppm² 286k.'),
|
||||
(12547, 'бизнес', true, 'high', 'desc: ЖК «Екатерининский парк», дом бизнес-класса (куратор РМК); ppm² 267k.'),
|
||||
(12678, 'бизнес', true, 'high', 'desc: ЖК «Вивальди» (назван премиум-классом), центр, кирпич 2017; ppm² 267k = бизнес.'),
|
||||
(10880, 'бизнес', true, 'high', 'desc: ЖК «Никольский» — «жизнь бизнес-класса в центре», набережная Исети; ppm² 265k.'),
|
||||
(9528, 'бизнес', true, 'medium', 'тот же адрес Тверитина 46 = ЖК «Вивальди» (см. hid 12678); descs null, ppm² 258k подтверждает бизнес.'),
|
||||
(8066, 'бизнес', true, 'medium', 'descs null; ppm² 218k попадает в бизнес-диапазон (160-250k) для ЕКБ; нет class-words для усиления.'),
|
||||
(376817, 'комфорт', false, 'low', 'numeric addr без улицы; descs смешаны (Сочи/Адлер апартаменты, ост. Юбилейный) — масс-маркет/чужой город, ppm² 169k раздут концентрацией дорогих лотов.'),
|
||||
(376374, 'эконом', false, 'low', 'numeric addr; descs — студия 26м² и 1к в Краснодаре (чужой город), ppm² 125k = эконом/комфорт, явно масс-маркет.'),
|
||||
(11054, 'элит', true, 'high', 'desc: клубный дом «Ривьера» (заявлен бизнес-класс), центр, авторская отделка; ppm² 640k = премиум-уровень цены.')
|
||||
ON CONFLICT (house_id) DO UPDATE SET
|
||||
tier = EXCLUDED.tier,
|
||||
is_premium = EXCLUDED.is_premium,
|
||||
confidence = EXCLUDED.confidence,
|
||||
note = EXCLUDED.note;
|
||||
|
||||
COMMIT;
|
||||
|
|
@ -1,10 +1,18 @@
|
|||
"""#2002: premium_building флаг из MV `premium_houses`.
|
||||
"""#2002: premium_building флаг + класс из curated overlay → MV `premium_houses`.
|
||||
|
||||
Покрывает read-only helper `_is_premium_building`:
|
||||
- target резолвится в премиальный дом → (True, med_ppm2);
|
||||
- target не в MV → (False, None);
|
||||
- house_id is None → (False, None) без обращения к БД;
|
||||
- MV отсутствует / SQL-ошибка → graceful (False, None), оценка не падает.
|
||||
Покрывает read-only helper `_is_premium_building`, который возвращает 3-кортеж
|
||||
`(is_premium, med_ppm2, building_class)` из двухслойного overlay:
|
||||
1. `premium_buildings_curated` (data/sql/142) — AI/human-выверенный слой (класс +
|
||||
is_premium с false-positive demotions); ПЕРЕБИВАЕТ MV;
|
||||
2. `premium_houses` MV (data/sql/139) — даёт med_ppm2 + fallback-членство.
|
||||
|
||||
Сценарии:
|
||||
- curated premium=True с классом → (True, MV.med_ppm2, tier);
|
||||
- curated premium=False демотит дом из MV → (False, med, tier) — false-positive;
|
||||
- невыверенный дом в MV → (True, med, None) — членство есть, класс неизвестен;
|
||||
- дом нигде → (False, None, None);
|
||||
- house_id is None → (False, None, None) без обращения к БД;
|
||||
- таблица отсутствует / SQL-ошибка → graceful (False, None, None), оценка не падает.
|
||||
|
||||
Плюс smoke-проверка дефолтов в response-модели AggregatedEstimate (флаг — метаданные,
|
||||
сериализация без изменений, когда дом не премиальный).
|
||||
|
|
@ -23,65 +31,117 @@ import pytest
|
|||
from app.services.estimator import _is_premium_building
|
||||
|
||||
|
||||
def _make_mock_db(*, first_row: Any, raises: bool = False) -> MagicMock:
|
||||
"""mock db.execute(...).first() → first_row; raises=True имитирует отсутствие MV."""
|
||||
def _make_mock_db(
|
||||
*, curated_row: Any = None, mv_row: Any = None, raises: bool = False
|
||||
) -> MagicMock:
|
||||
"""mock db.execute, маршрутизируя по SQL-тексту.
|
||||
|
||||
`SELECT ... FROM premium_buildings_curated ...` → curated_row;
|
||||
`SELECT med_ppm2 FROM premium_houses ...` → mv_row.
|
||||
raises=True имитирует отсутствие таблицы (первый же execute бросает).
|
||||
"""
|
||||
mock_db = MagicMock()
|
||||
if raises:
|
||||
mock_db.execute.side_effect = RuntimeError('relation "premium_houses" does not exist')
|
||||
else:
|
||||
mock_db.execute.return_value.first.return_value = first_row
|
||||
mock_db.execute.side_effect = RuntimeError(
|
||||
'relation "premium_buildings_curated" does not exist'
|
||||
)
|
||||
return mock_db
|
||||
|
||||
def _execute(sql: Any, params: Any = None) -> MagicMock:
|
||||
text_sql = str(getattr(sql, "text", sql))
|
||||
result = MagicMock()
|
||||
if "premium_buildings_curated" in text_sql:
|
||||
result.first.return_value = curated_row
|
||||
elif "premium_houses" in text_sql:
|
||||
result.first.return_value = mv_row
|
||||
else: # pragma: no cover — defensive
|
||||
result.first.return_value = None
|
||||
return result
|
||||
|
||||
mock_db.execute.side_effect = _execute
|
||||
return mock_db
|
||||
|
||||
|
||||
def test_premium_house_yields_true_with_median() -> None:
|
||||
"""house_id в premium_houses → (True, med_ppm2)."""
|
||||
db = _make_mock_db(first_row=(255_000,))
|
||||
|
||||
is_premium, median = _is_premium_building(db, 4242)
|
||||
|
||||
assert is_premium is True
|
||||
assert median == 255_000
|
||||
# CAST(:hid AS bigint) — psycopg v3, не :hid::bigint
|
||||
sql = str(db.execute.call_args.args[0].text)
|
||||
assert "CAST(:hid AS bigint)" in sql
|
||||
assert "::bigint" not in sql
|
||||
def _executed_sql(db: MagicMock) -> list[str]:
|
||||
"""Тексты всех SQL-запросов, прошедших через db.execute."""
|
||||
return [str(call.args[0].text) for call in db.execute.call_args_list]
|
||||
|
||||
|
||||
def test_non_premium_house_yields_false_none() -> None:
|
||||
"""house_id отсутствует в MV (first() → None) → (False, None)."""
|
||||
db = _make_mock_db(first_row=None)
|
||||
def test_curated_premium_with_class_overrides_mv() -> None:
|
||||
"""curated is_premium=True + tier → (True, MV.med_ppm2, tier); класс из curated."""
|
||||
db = _make_mock_db(curated_row=(True, "премиум"), mv_row=(422_000,))
|
||||
|
||||
assert _is_premium_building(db, 9999) == (False, None)
|
||||
assert _is_premium_building(db, 11370) == (True, 422_000, "премиум")
|
||||
# обе таблицы используют CAST(:hid AS bigint) — psycopg v3, не :hid::bigint
|
||||
sqls = _executed_sql(db)
|
||||
assert any("premium_buildings_curated" in s for s in sqls)
|
||||
assert any("premium_houses" in s for s in sqls)
|
||||
for s in sqls:
|
||||
assert "CAST(:hid AS bigint)" in s
|
||||
assert "::bigint" not in s
|
||||
|
||||
|
||||
def test_curated_false_positive_demotes_mv_member() -> None:
|
||||
"""curated is_premium=False демотит дом-член MV → (False, med, tier).
|
||||
|
||||
Концентрационный MV пометил дом премиальным, но curation признал его
|
||||
false-positive (mass-market). curated перебивает — флаг False, но med и класс
|
||||
из данных сохраняются как контекст для ревьюера.
|
||||
"""
|
||||
db = _make_mock_db(curated_row=(False, "комфорт"), mv_row=(211_000,))
|
||||
|
||||
assert _is_premium_building(db, 9401) == (False, 211_000, "комфорт")
|
||||
|
||||
|
||||
def test_curated_class_flows_when_mv_absent() -> None:
|
||||
"""curated есть, дома нет в MV → (curated.is_premium, None, tier)."""
|
||||
db = _make_mock_db(curated_row=(True, "бизнес"), mv_row=None)
|
||||
|
||||
assert _is_premium_building(db, 6685) == (True, None, "бизнес")
|
||||
|
||||
|
||||
def test_uncurated_mv_member_premium_true_class_none() -> None:
|
||||
"""Невыверенный дом, но член MV → (True, med, None) — класс неизвестен."""
|
||||
db = _make_mock_db(curated_row=None, mv_row=(255_000,))
|
||||
|
||||
assert _is_premium_building(db, 4242) == (True, 255_000, None)
|
||||
|
||||
|
||||
def test_not_premium_anywhere_yields_false_none_none() -> None:
|
||||
"""Дом отсутствует и в curated, и в MV → (False, None, None)."""
|
||||
db = _make_mock_db(curated_row=None, mv_row=None)
|
||||
|
||||
assert _is_premium_building(db, 9999) == (False, None, None)
|
||||
|
||||
|
||||
def test_house_id_none_short_circuits_without_query() -> None:
|
||||
"""house_id is None → (False, None), к БД не обращаемся."""
|
||||
db = _make_mock_db(first_row=(255_000,))
|
||||
"""house_id is None → (False, None, None), к БД не обращаемся."""
|
||||
db = _make_mock_db(curated_row=(True, "премиум"), mv_row=(255_000,))
|
||||
|
||||
assert _is_premium_building(db, None) == (False, None)
|
||||
assert _is_premium_building(db, None) == (False, None, None)
|
||||
db.execute.assert_not_called()
|
||||
|
||||
|
||||
def test_missing_mv_degrades_gracefully() -> None:
|
||||
"""MV не существует / SQL-ошибка → (False, None), без проброса исключения."""
|
||||
db = _make_mock_db(first_row=None, raises=True)
|
||||
def test_missing_table_degrades_gracefully() -> None:
|
||||
"""Таблица не существует / SQL-ошибка → (False, None, None), без проброса."""
|
||||
db = _make_mock_db(raises=True)
|
||||
|
||||
assert _is_premium_building(db, 4242) == (False, None)
|
||||
assert _is_premium_building(db, 4242) == (False, None, None)
|
||||
# после ошибки откатываем poisoned-транзакцию
|
||||
db.rollback.assert_called_once()
|
||||
|
||||
|
||||
def test_premium_median_null_in_mv_is_handled() -> None:
|
||||
"""Дом в MV, но med_ppm2 NULL → (True, None) (премиум-флаг есть, медианы нет)."""
|
||||
db = _make_mock_db(first_row=(None,))
|
||||
def test_mv_median_null_is_handled() -> None:
|
||||
"""Дом в MV, но med_ppm2 NULL → median None (флаг/класс не теряются)."""
|
||||
db = _make_mock_db(curated_row=(True, "элит"), mv_row=(None,))
|
||||
|
||||
assert _is_premium_building(db, 4242) == (True, None)
|
||||
assert _is_premium_building(db, 7109) == (True, None, "элит")
|
||||
|
||||
|
||||
def test_response_model_defaults_non_premium() -> None:
|
||||
"""AggregatedEstimate по умолчанию: premium_building=False, median=None.
|
||||
"""AggregatedEstimate по умолчанию: premium_building=False, median/class=None.
|
||||
|
||||
Гарантирует, что добавление флага не меняет сериализацию для не-премиум оценок.
|
||||
Гарантирует, что добавление полей не меняет сериализацию для не-премиум оценок.
|
||||
"""
|
||||
from datetime import UTC, datetime
|
||||
from uuid import uuid4
|
||||
|
|
@ -104,9 +164,38 @@ def test_response_model_defaults_non_premium() -> None:
|
|||
|
||||
assert est.premium_building is False
|
||||
assert est.premium_building_median_ppm2 is None
|
||||
assert est.premium_building_class is None
|
||||
dumped = est.model_dump()
|
||||
assert dumped["premium_building"] is False
|
||||
assert dumped["premium_building_median_ppm2"] is None
|
||||
assert dumped["premium_building_class"] is None
|
||||
|
||||
|
||||
def test_response_model_carries_curated_class() -> None:
|
||||
"""premium_building_class сериализуется, когда задан (curated overlay)."""
|
||||
from datetime import UTC, datetime
|
||||
from uuid import uuid4
|
||||
|
||||
from app.schemas.trade_in import AggregatedEstimate
|
||||
|
||||
est = AggregatedEstimate(
|
||||
estimate_id=uuid4(),
|
||||
median_price_rub=42_000_000,
|
||||
range_low_rub=40_000_000,
|
||||
range_high_rub=44_000_000,
|
||||
median_price_per_m2=420_000,
|
||||
confidence="medium",
|
||||
n_analogs=8,
|
||||
period_months=24,
|
||||
analogs=[],
|
||||
actual_deals=[],
|
||||
expires_at=datetime.now(tz=UTC),
|
||||
premium_building=True,
|
||||
premium_building_median_ppm2=422_000,
|
||||
premium_building_class="премиум",
|
||||
)
|
||||
|
||||
assert est.model_dump()["premium_building_class"] == "премиум"
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue